diff --git a/ical/event.py b/ical/event.py index 8d7aff8..a73041a 100644 --- a/ical/event.py +++ b/ical/event.py @@ -39,6 +39,7 @@ RecurrenceId, RequestStatus, Uri, + RelatedTo, ) from .util import dtstamp_factory, normalize_datetime, uid_factory @@ -166,9 +167,12 @@ class Event(ComponentModel): instance within the recurrence set. """ - related: list[str] = Field(default_factory=list) + related_to: list[RelatedTo] = Field(alias="related-to", default_factory=list) """Used to represent a relationship or reference between events.""" + related: list[str] = Field(default_factory=list) + """Unused and will be deleted in a future release""" + resources: list[str] = Field(default_factory=list) """Defines the equipment or resources anticipated for the calendar event.""" @@ -438,4 +442,6 @@ def _validate_duration_unit(cls, values: dict[str, Any]) -> dict[str, Any]: return values _validate_until_dtstart = root_validator(allow_reuse=True)(validate_until_dtstart) - _validate_recurrence_dates = root_validator(allow_reuse=True)(validate_recurrence_dates) + _validate_recurrence_dates = root_validator(allow_reuse=True)( + validate_recurrence_dates + ) diff --git a/ical/journal.py b/ical/journal.py index 89cc8eb..dbf3b6c 100644 --- a/ical/journal.py +++ b/ical/journal.py @@ -16,7 +16,7 @@ from .component import ComponentModel, validate_until_dtstart, validate_recurrence_dates from .parsing.property import ParsedProperty -from .types import CalAddress, Classification, Recur, RecurrenceId, RequestStatus, Uri +from .types import CalAddress, Classification, Recur, RecurrenceId, RequestStatus, Uri, RelatedTo from .util import dtstamp_factory, normalize_datetime, uid_factory _LOGGER = logging.getLogger(__name__) @@ -63,6 +63,10 @@ class Journal(ComponentModel): ) organizer: Optional[CalAddress] = None recurrence_id: Optional[RecurrenceId] = Field(alias="recurrence-id") + + related_to: list[RelatedTo] = Field(alias="related-to", default_factory=list) + """Used to represent a relationship or reference between events.""" + related: list[str] = Field(default_factory=list) rrule: Optional[Recur] = None rdate: list[Union[datetime.datetime, datetime.date]] = Field(default_factory=list) diff --git a/ical/todo.py b/ical/todo.py index 84d293d..17144bc 100644 --- a/ical/todo.py +++ b/ical/todo.py @@ -23,6 +23,7 @@ RecurrenceId, RequestStatus, Uri, + RelatedTo, ) from .util import dtstamp_factory, normalize_datetime, uid_factory @@ -68,6 +69,10 @@ class Todo(ComponentModel): percent: Optional[int] = None priority: Optional[Priority] = None recurrence_id: Optional[RecurrenceId] = Field(alias="recurrence-id") + + related_to: list[RelatedTo] = Field(alias="related-to", default_factory=list) + """Used to represent a relationship or reference between events.""" + request_status: Optional[RequestStatus] = Field( alias="request-status", default_value=None, diff --git a/ical/types/__init__.py b/ical/types/__init__.py index 21bb6b4..dc3ebf5 100644 --- a/ical/types/__init__.py +++ b/ical/types/__init__.py @@ -10,6 +10,7 @@ from .period import FreeBusyType, Period from .priority import Priority from .recur import Frequency, Range, Recur, RecurrenceId, Weekday, WeekdayValue +from .relation import RelatedTo, RelationshipType from .request_status import RequestStatus from .uri import Uri from .utc_offset import UtcOffset @@ -25,6 +26,8 @@ "Range", "Recur", "RecurrenceId", + "RelatedTo", + "RelationshipType", "RequestStatus", "UtcOffset", "Uri", diff --git a/ical/types/cal_address.py b/ical/types/cal_address.py index e6ad790..32169d3 100644 --- a/ical/types/cal_address.py +++ b/ical/types/cal_address.py @@ -56,10 +56,7 @@ class Role(str, enum.Enum): @DATA_TYPE.register("CAL-ADDRESS") class CalAddress(BaseModel): - """A value type for a property that contains a calendar user address. - This is a subclass of string so that it can be used in place of a string - to get the calendar address, but also supports additional properties. - """ + """A value type for a property that contains a calendar user address.""" uri: Uri = Field(alias="value") """The calendar user address as a uri.""" diff --git a/ical/types/relation.py b/ical/types/relation.py new file mode 100644 index 0000000..05fdbd8 --- /dev/null +++ b/ical/types/relation.py @@ -0,0 +1,69 @@ +"""Implementation of the RELATED-TO property.""" + +import enum +from dataclasses import dataclass +from typing import Any +import logging + +try: + from pydantic.v1 import root_validator +except ImportError: + from pydantic import root_validator + +from .data_types import DATA_TYPE +from ical.parsing.property import ParsedProperty, ParsedPropertyParameter +from .parsing import parse_parameter_values + + +class RelationshipType(str, enum.Enum): + """Type of hierarchical relationship associated with the calendar component.""" + + PARENT = "PARENT" + """Parent relationship - Default.""" + + CHILD = "CHILD" + """Child relationship.""" + + SIBBLING = "SIBBLING" + """Sibling relationship.""" + + +@DATA_TYPE.register("RELATED-TO") +@dataclass +class RelatedTo: + """Used to represent a relationship or reference between one calendar component and another.""" + + uid: str + """The value of the related-to property is the persistent, globally unique identifier of another calendar component.""" + + reltype: RelationshipType = RelationshipType.PARENT + """Indicate the type of hierarchical relationship associated with the calendar component specified by the uid.""" + + @classmethod + def __parse_property_value__(cls, prop: Any) -> int: + """Parse a rfc5545 int value.""" + logging.info("prop=%s", prop) + if isinstance(prop, ParsedProperty): + data = {"uid": prop.value} + for param in prop.params or (): + if len(param.values) > 1: + raise ValueError("Expected only one value for RELATED-TO parameter") + data[param.name] = param.values[0] + return data + return {"uid": prop} + + _parse_parameter_values = root_validator(pre=True, allow_reuse=True)( + parse_parameter_values + ) + + @classmethod + def __encode_property_value__(cls, model_data: dict[str, str]) -> str | None: + return model_data.pop("uid") + + @classmethod + def __encode_property_params__( + cls, model_data: dict[str, Any] + ) -> list[ParsedPropertyParameter]: + if "reltype" not in model_data: + return [] + return [ParsedPropertyParameter(name="RELTYPE", values=[model_data["reltype"]])] diff --git a/tests/testdata/related_to.yaml b/tests/testdata/related_to.yaml new file mode 100644 index 0000000..bce74cb --- /dev/null +++ b/tests/testdata/related_to.yaml @@ -0,0 +1,64 @@ +input: |- + BEGIN:VCALENDAR + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + VERSION:2.0 + BEGIN:VTODO + UID:20070313T123432Z-456553@example.com + DTSTAMP:20070313T123432Z + DUE;VALUE=DATE:20070501 + SUMMARY:Submit Quebec Income Tax Return for 2006 + CLASS:CONFIDENTIAL + CATEGORIES:FAMILY,FINANCE + STATUS:NEEDS-ACTION + END:VTODO + BEGIN:VTODO + UID:20070313T123432Z-456554@example.com + DTSTAMP:20070313T123432Z + SUMMARY:Buy pens + STATUS:NEEDS-ACTION + RELATED-TO;RELTYPE=PARENT:20070313T123432Z-456553@example.com + END:VTODO + END:VCALENDAR +output: + calendars: + - prodid: -//hacksw/handcal//NONSGML v1.0//EN + version: '2.0' + todos: + - dtstamp: '2007-03-13T12:34:32+00:00' + uid: 20070313T123432Z-456553@example.com + due: '2007-05-01' + status: NEEDS-ACTION + classification: CONFIDENTIAL + categories: + - FAMILY + - FINANCE + summary: Submit Quebec Income Tax Return for 2006 + - dtstamp: '2007-03-13T12:34:32+00:00' + uid: 20070313T123432Z-456554@example.com + status: NEEDS-ACTION + summary: Buy pens + related_to: + - uid: 20070313T123432Z-456553@example.com + reltype: PARENT +encoded: |- + BEGIN:VCALENDAR + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20070313T123432Z + UID:20070313T123432Z-456553@example.com + CATEGORIES:FAMILY + CATEGORIES:FINANCE + CLASS:CONFIDENTIAL + DUE:20070501 + STATUS:NEEDS-ACTION + SUMMARY:Submit Quebec Income Tax Return for 2006 + END:VTODO + BEGIN:VTODO + DTSTAMP:20070313T123432Z + UID:20070313T123432Z-456554@example.com + RELATED-TO;RELTYPE=PARENT:20070313T123432Z-456553@example.com + STATUS:NEEDS-ACTION + SUMMARY:Buy pens + END:VTODO + END:VCALENDAR diff --git a/tests/testdata/related_to_default.yaml b/tests/testdata/related_to_default.yaml new file mode 100644 index 0000000..8b34fc7 --- /dev/null +++ b/tests/testdata/related_to_default.yaml @@ -0,0 +1,64 @@ +input: |- + BEGIN:VCALENDAR + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + VERSION:2.0 + BEGIN:VTODO + UID:20070313T123432Z-456553@example.com + DTSTAMP:20070313T123432Z + DUE;VALUE=DATE:20070501 + SUMMARY:Submit Quebec Income Tax Return for 2006 + CLASS:CONFIDENTIAL + CATEGORIES:FAMILY,FINANCE + STATUS:NEEDS-ACTION + END:VTODO + BEGIN:VTODO + UID:20070313T123432Z-456554@example.com + DTSTAMP:20070313T123432Z + SUMMARY:Buy pens + STATUS:NEEDS-ACTION + RELATED-TO:20070313T123432Z-456553@example.com + END:VTODO + END:VCALENDAR +output: + calendars: + - prodid: -//hacksw/handcal//NONSGML v1.0//EN + version: '2.0' + todos: + - dtstamp: '2007-03-13T12:34:32+00:00' + uid: 20070313T123432Z-456553@example.com + due: '2007-05-01' + status: NEEDS-ACTION + classification: CONFIDENTIAL + categories: + - FAMILY + - FINANCE + summary: Submit Quebec Income Tax Return for 2006 + - dtstamp: '2007-03-13T12:34:32+00:00' + uid: 20070313T123432Z-456554@example.com + status: NEEDS-ACTION + summary: Buy pens + related_to: + - uid: 20070313T123432Z-456553@example.com + reltype: PARENT +encoded: |- + BEGIN:VCALENDAR + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20070313T123432Z + UID:20070313T123432Z-456553@example.com + CATEGORIES:FAMILY + CATEGORIES:FINANCE + CLASS:CONFIDENTIAL + DUE:20070501 + STATUS:NEEDS-ACTION + SUMMARY:Submit Quebec Income Tax Return for 2006 + END:VTODO + BEGIN:VTODO + DTSTAMP:20070313T123432Z + UID:20070313T123432Z-456554@example.com + RELATED-TO;RELTYPE=PARENT:20070313T123432Z-456553@example.com + STATUS:NEEDS-ACTION + SUMMARY:Buy pens + END:VTODO + END:VCALENDAR diff --git a/tests/types/test_related.py b/tests/types/test_related.py new file mode 100644 index 0000000..446c351 --- /dev/null +++ b/tests/types/test_related.py @@ -0,0 +1,139 @@ +"""Tests for RELATED-TO data types.""" + + +import pytest +from ical.exceptions import CalendarParseError + +from ical.component import ComponentModel +from ical.parsing.component import ParsedComponent +from ical.parsing.property import ParsedProperty, ParsedPropertyParameter +from ical.types import RelatedTo, RelationshipType +from ical.types.data_types import DATA_TYPE + + +class FakeModel(ComponentModel): + """Model under test.""" + + example: RelatedTo + + class Config: + """Pydantic model configuration.""" + + json_encoders = DATA_TYPE.encode_property_json + + +def test_default_reltype() -> None: + """Test for no explicit reltype specified.""" + model = FakeModel.parse_obj( + { + "example": [ + ParsedProperty( + name="example", + value="example-uid@example.com", + params=[ParsedPropertyParameter(name="RELTYPE", values=["PARENT"])], + ) + ] + }, + ) + assert model.example + assert model.example.uid == "example-uid@example.com" + assert model.example.reltype == "PARENT" + + +@pytest.mark.parametrize( + "reltype", + [ + ("PARENT"), + ("CHILD"), + ("SIBBLING"), + ], +) +def test_reltype(reltype: str) -> None: + """Test for no explicit reltype specified.""" + + model = FakeModel.parse_obj( + { + "example": [ + ParsedProperty( + name="example", + value="example-uid@example.com", + params=[ParsedPropertyParameter(name="reltype", values=[reltype])], + ) + ] + }, + ) + assert model.example + assert model.example.uid == "example-uid@example.com" + assert model.example.reltype == reltype + + +def test_invalid_reltype() -> None: + with pytest.raises(CalendarParseError): + FakeModel.parse_obj( + { + "example": [ + ParsedProperty( + name="example", + value="example-uid@example.com", + params=[ + ParsedPropertyParameter( + name="reltype", values=["invalid-reltype"] + ) + ], + ) + ] + }, + ) + + +def test_too_many_reltype_values() -> None: + with pytest.raises(CalendarParseError): + FakeModel.parse_obj( + { + "example": [ + ParsedProperty( + name="example", + value="example-uid@example.com", + params=[ + ParsedPropertyParameter( + name="reltype", values=["PARENT", "SIBBLING"] + ) + ], + ) + ] + }, + ) + + +def test_encode_default_reltype() -> None: + """Test encoded period.""" + + model = FakeModel(example=RelatedTo(uid="example-uid@example.com")) + assert model.__encode_component_root__() == ParsedComponent( + name="FakeModel", + properties=[ + ParsedProperty( + name="example", + value="example-uid@example.com", + params=[ParsedPropertyParameter(name="RELTYPE", values=["PARENT"])], + ), + ], + ) + + +def test_encode_reltype() -> None: + """Test encoded period.""" + + model = FakeModel( + example=RelatedTo(uid="example-uid@example.com", reltype=RelationshipType.CHILD) + ) + assert model.__encode_component_root__() == ParsedComponent( + name="FakeModel", + properties=[ + ParsedProperty( + name="example", + value="example-uid@example.com", + params=[ParsedPropertyParameter(name="RELTYPE", values=["CHILD"])], + ), + ], + )