1""":rfc:`5545` VTODO component."""
2
3from __future__ import annotations
4
5import uuid
6from datetime import date, datetime, timedelta
7from typing import TYPE_CHECKING, Literal, Sequence
8
9from icalendar.attr import (
10 X_MOZ_LASTACK_property,
11 X_MOZ_SNOOZE_TIME_property,
12 attendees_property,
13 categories_property,
14 class_property,
15 color_property,
16 contacts_property,
17 create_single_property,
18 description_property,
19 exdates_property,
20 location_property,
21 organizer_property,
22 priority_property,
23 property_del_duration,
24 property_doc_duration_template,
25 property_get_duration,
26 property_set_duration,
27 rdates_property,
28 rrules_property,
29 sequence_property,
30 status_property,
31 summary_property,
32 uid_property,
33 url_property,
34)
35from icalendar.cal.component import Component
36from icalendar.error import IncompleteComponent, InvalidCalendar
37from icalendar.tools import is_date
38
39if TYPE_CHECKING:
40 from icalendar.alarms import Alarms
41 from icalendar.enums import CLASS, STATUS
42 from icalendar.prop import vCalAddress
43
44
45class Todo(Component):
46 """
47 A "VTODO" calendar component is a grouping of component
48 properties that represents an action item or assignment. For
49 example, it can be used to represent an item of work assigned to
50 an individual, such as "Prepare for the upcoming conference
51 seminar on Internet Calendaring".
52
53 Examples:
54 Create a new Todo:
55
56 >>> from icalendar import Todo
57 >>> todo = Todo.new()
58 >>> print(todo.to_ical())
59 BEGIN:VTODO
60 DTSTAMP:20250517T080612Z
61 UID:d755cef5-2311-46ed-a0e1-6733c9e15c63
62 END:VTODO
63
64 """
65
66 name = "VTODO"
67
68 required = (
69 "UID",
70 "DTSTAMP",
71 )
72 singletons = (
73 "CLASS",
74 "COLOR",
75 "COMPLETED",
76 "CREATED",
77 "DESCRIPTION",
78 "DTSTAMP",
79 "DTSTART",
80 "GEO",
81 "LAST-MODIFIED",
82 "LOCATION",
83 "ORGANIZER",
84 "PERCENT-COMPLETE",
85 "PRIORITY",
86 "RECURRENCE-ID",
87 "SEQUENCE",
88 "STATUS",
89 "SUMMARY",
90 "UID",
91 "URL",
92 "DUE",
93 "DURATION",
94 )
95 exclusive = (
96 "DUE",
97 "DURATION",
98 )
99 multiple = (
100 "ATTACH",
101 "ATTENDEE",
102 "CATEGORIES",
103 "COMMENT",
104 "CONTACT",
105 "EXDATE",
106 "RSTATUS",
107 "RELATED",
108 "RESOURCES",
109 "RDATE",
110 "RRULE",
111 )
112 DTSTART = create_single_property(
113 "DTSTART",
114 "dt",
115 (datetime, date),
116 date,
117 'The "DTSTART" property for a "VTODO" specifies the inclusive start of the Todo.', # noqa: E501
118 )
119 DUE = create_single_property(
120 "DUE",
121 "dt",
122 (datetime, date),
123 date,
124 'The "DUE" property for a "VTODO" calendar component specifies the non-inclusive end of the Todo.', # noqa: E501
125 )
126 DURATION = property(
127 property_get_duration,
128 property_set_duration,
129 property_del_duration,
130 property_doc_duration_template.format(component="VTODO"),
131 )
132
133 def _get_start_end_duration(self):
134 """Verify the calendar validity and return the right attributes."""
135 start = self.DTSTART
136 end = self.DUE
137 duration = self.DURATION
138 if duration is not None and end is not None:
139 raise InvalidCalendar(
140 "Only one of DUE and DURATION may be in a VTODO, not both."
141 )
142 if (
143 start is not None
144 and is_date(start)
145 and duration is not None
146 and duration.seconds != 0
147 ):
148 raise InvalidCalendar(
149 "When DTSTART is a date, DURATION must be of days or weeks."
150 )
151 if start is not None and end is not None and is_date(start) != is_date(end):
152 raise InvalidCalendar(
153 "DTSTART and DUE must be of the same type, either date or datetime."
154 )
155 return start, end, duration
156
157 @property
158 def start(self) -> date | datetime:
159 """The start of the VTODO.
160
161 Invalid values raise an InvalidCalendar.
162 If there is no start, we also raise an IncompleteComponent error.
163
164 You can get the start, end and duration of a Todo as follows:
165
166 >>> from datetime import datetime
167 >>> from icalendar import Todo
168 >>> todo = Todo()
169 >>> todo.start = datetime(2021, 1, 1, 12)
170 >>> todo.end = datetime(2021, 1, 1, 12, 30) # 30 minutes
171 >>> todo.duration # 1800 seconds == 30 minutes
172 datetime.timedelta(seconds=1800)
173 >>> print(todo.to_ical())
174 BEGIN:VTODO
175 DTSTART:20210101T120000
176 DUE:20210101T123000
177 END:VTODO
178 """
179 start = self._get_start_end_duration()[0]
180 if start is None:
181 raise IncompleteComponent("No DTSTART given.")
182 return start
183
184 @start.setter
185 def start(self, start: date | datetime | None):
186 """Set the start."""
187 self.DTSTART = start
188
189 @property
190 def end(self) -> date | datetime:
191 """The end of the todo.
192
193 Invalid values raise an InvalidCalendar error.
194 If there is no end, we also raise an IncompleteComponent error.
195 """
196 start, end, duration = self._get_start_end_duration()
197 if end is None and duration is None:
198 if start is None:
199 raise IncompleteComponent("No DUE or DURATION+DTSTART given.")
200 if is_date(start):
201 return start + timedelta(days=1)
202 return start
203 if duration is not None:
204 if start is not None:
205 return start + duration
206 raise IncompleteComponent("No DUE or DURATION+DTSTART given.")
207 return end
208
209 @end.setter
210 def end(self, end: date | datetime | None):
211 """Set the end."""
212 self.DUE = end
213
214 @property
215 def duration(self) -> timedelta:
216 """The duration of the VTODO.
217
218 Returns the DURATION property if set, otherwise calculated from start and end.
219 You can set the duration to automatically adjust the end time while keeping
220 start locked.
221
222 Setting the duration will:
223 1. Keep the start time locked (unchanged)
224 2. Adjust the end time to start + duration
225 3. Remove any existing DUE property
226 4. Set the DURATION property
227 """
228 # First check if DURATION property is explicitly set
229 if "DURATION" in self:
230 return self["DURATION"].dt
231
232 # Fall back to calculated duration from start and end
233 return self.end - self.start
234
235 @duration.setter
236 def duration(self, value: timedelta):
237 if not isinstance(value, timedelta):
238 raise TypeError(f"Use timedelta, not {type(value).__name__}.")
239
240 # Use the set_duration method with default start-locked behavior
241 self.set_duration(value, locked="start")
242
243 def set_duration(
244 self, duration: timedelta | None, locked: Literal["start", "end"] = "start"
245 ):
246 """Set the duration of the event relative to either start or end.
247
248 Args:
249 duration: The duration to set, or None to convert to DURATION property
250 locked: Which property to keep unchanged ('start' or 'end')
251 """
252 from icalendar.attr import set_duration_with_locking
253
254 set_duration_with_locking(self, duration, locked, "DUE")
255
256 def set_start(
257 self, start: date | datetime, locked: Literal["duration", "end"] | None = None
258 ):
259 """Set the start with explicit locking behavior.
260
261 Args:
262 start: The start time to set
263 locked: Which property to keep unchanged ('duration', 'end', or None
264 for auto-detect)
265 """
266 if locked is None:
267 # Auto-detect based on existing properties
268 if "DURATION" in self:
269 locked = "duration"
270 elif "DUE" in self:
271 locked = "end"
272 else:
273 # Default to duration if no existing properties
274 locked = "duration"
275
276 if locked == "duration":
277 # Keep duration locked, adjust end
278 current_duration = (
279 self.duration if "DURATION" in self or "DUE" in self else None
280 )
281 self.DTSTART = start
282 if current_duration is not None:
283 self.DURATION = current_duration
284 elif locked == "end":
285 # Keep end locked, adjust duration
286 current_end = self.end
287 self.DTSTART = start
288 self.pop("DURATION", None)
289 self.DUE = current_end
290 else:
291 raise ValueError(
292 f"locked must be 'duration', 'end', or None, not {locked!r}"
293 )
294
295 def set_end(
296 self, end: date | datetime, locked: Literal["start", "duration"] = "start"
297 ):
298 """Set the end of the component, keeping either the start or the duration same.
299
300 Args:
301 end: The end time to set
302 locked: Which property to keep unchanged ('start' or 'duration')
303 """
304 if locked == "start":
305 # Keep start locked, adjust duration
306 self.pop("DURATION", None)
307 self.DUE = end
308 elif locked == "duration":
309 # Keep duration locked, adjust start
310 current_duration = self.duration
311 self.DTSTART = end - current_duration
312 self.DURATION = current_duration
313 else:
314 raise ValueError(f"locked must be 'start' or 'duration', not {locked!r}")
315
316 X_MOZ_SNOOZE_TIME = X_MOZ_SNOOZE_TIME_property
317 X_MOZ_LASTACK = X_MOZ_LASTACK_property
318
319 @property
320 def alarms(self) -> Alarms:
321 """Compute the alarm times for this component.
322
323 >>> from datetime import datetime
324 >>> from icalendar import Todo
325 >>> todo = Todo() # empty without alarms
326 >>> todo.start = datetime(2024, 10, 26, 10, 21)
327 >>> len(todo.alarms.times)
328 0
329
330 Note that this only uses DTSTART and DUE, but ignores
331 RDATE, EXDATE, and RRULE properties.
332 """
333 from icalendar.alarms import Alarms
334
335 return Alarms(self)
336
337 color = color_property
338 sequence = sequence_property
339 categories = categories_property
340 rdates = rdates_property
341 exdates = exdates_property
342 rrules = rrules_property
343 uid = uid_property
344 summary = summary_property
345 description = description_property
346 classification = class_property
347 url = url_property
348 organizer = organizer_property
349 location = location_property
350 priority = priority_property
351 contacts = contacts_property
352 status = status_property
353 attendees = attendees_property
354
355 @classmethod
356 def new(
357 cls,
358 /,
359 attendees: list[vCalAddress] | None = None,
360 categories: Sequence[str] = (),
361 classification: CLASS | None = None,
362 color: str | None = None,
363 comments: list[str] | str | None = None,
364 contacts: list[str] | str | None = None,
365 created: date | None = None,
366 description: str | None = None,
367 end: date | datetime | None = None,
368 last_modified: date | None = None,
369 location: str | None = None,
370 organizer: vCalAddress | str | None = None,
371 priority: int | None = None,
372 sequence: int | None = None,
373 stamp: date | None = None,
374 start: date | datetime | None = None,
375 status: STATUS | None = None,
376 summary: str | None = None,
377 uid: str | uuid.UUID | None = None,
378 url: str | None = None,
379 ):
380 """Create a new TODO with all required properties.
381
382 This creates a new Todo in accordance with :rfc:`5545`.
383
384 Arguments:
385 attendees: The :attr:`attendees` of the todo.
386 categories: The :attr:`categories` of the todo.
387 classification: The :attr:`classification` of the todo.
388 color: The :attr:`color` of the todo.
389 comments: The :attr:`Component.comments` of the todo.
390 created: The :attr:`Component.created` of the todo.
391 description: The :attr:`description` of the todo.
392 end: The :attr:`end` of the todo.
393 last_modified: The :attr:`Component.last_modified` of the todo.
394 location: The :attr:`location` of the todo.
395 organizer: The :attr:`organizer` of the todo.
396 sequence: The :attr:`sequence` of the todo.
397 stamp: The :attr:`Component.DTSTAMP` of the todo.
398 If None, this is set to the current time.
399 start: The :attr:`start` of the todo.
400 status: The :attr:`status` of the todo.
401 summary: The :attr:`summary` of the todo.
402 uid: The :attr:`uid` of the todo.
403 If None, this is set to a new :func:`uuid.uuid4`.
404 url: The :attr:`url` of the todo.
405
406 Returns:
407 :class:`Todo`
408
409 Raises:
410 InvalidCalendar: If the content is not valid according to :rfc:`5545`.
411
412 .. warning:: As time progresses, we will be stricter with the validation.
413 """
414 todo = super().new(
415 stamp=stamp if stamp is not None else cls._utc_now(),
416 created=created,
417 last_modified=last_modified,
418 comments=comments,
419 )
420 todo.summary = summary
421 todo.description = description
422 todo.uid = uid if uid is not None else uuid.uuid4()
423 todo.start = start
424 todo.end = end
425 todo.color = color
426 todo.categories = categories
427 todo.sequence = sequence
428 todo.classification = classification
429 todo.url = url
430 todo.organizer = organizer
431 todo.location = location
432 todo.priority = priority
433 todo.contacts = contacts
434 todo.status = status
435 todo.attendees = attendees
436 if cls._validate_new:
437 cls._validate_start_and_end(start, end)
438 return todo
439
440
441__all__ = ["Todo"]