Merge pull request #2146 from pbiering/sharing-conv-bday-template

Sharing conv bday template support
This commit is contained in:
Peter Bieringer
2026-05-31 09:26:40 +03:00
committed by GitHub
16 changed files with 1169 additions and 55 deletions

View File

@@ -1,6 +1,12 @@
# Changelog # Changelog
## 3.7.5.dev ## 3.7.5.dev
* Add: [sharing] conversion_bday_summary_template (customize summary)
* Add: [sharing] conversion_bday_description_template (customize description)
* Add: [sharing] conversion_bday_alarm_trigger_template (customize alarms)
* Add: [sharing] conversion_bday_categories (customize)
* Add: [sharing] conversion_bday_age_max (limit in case of "age" placeholder is used which blocks using RRULE)
* Extension: [sharing/bday conversion]: add STATUS + CLASS fields
## 3.7.4 ## 3.7.4
* Fix: sharing: PROPFIND returns now empty owner element in case of a mapped share as clients try PROPFIND on this not accessable href * Fix: sharing: PROPFIND returns now empty owner element in case of a mapped share as clients try PROPFIND on this not accessable href

View File

@@ -2287,6 +2287,8 @@ Default: `true`
##### default_permissions_create_token ##### default_permissions_create_token
_(>= 3.7.0)_
Default permissions for create token-based sharing Default permissions for create token-based sharing
Default: `r` Default: `r`
@@ -2295,12 +2297,77 @@ Supported: `rwEePp`
##### default_permissions_create_map ##### default_permissions_create_map
_(>= 3.7.0)_
Default permissions for map-based sharing Default permissions for map-based sharing
Default: `r` Default: `r`
Supported: `rwEePp` Supported: `rwEePp`
##### conversion_bday_summary_template
_(>= 3.7.5)_
Global template for summary of conversion "bday"
Default: `{{n:f} {n:g}|{fn}|{nickname}} ({year}) (BDAY)`
Supported placeholders (data used from VCARD)
* `{year}`: year of birthday (RFC6350#6.2.5)
* `{month}`: month of birthday (RFC6350#6.2.5)
* `{day}`: day of birthday (RFC6350#6.2.5)
* `{fn}`: full name (RFC6350#6.2.1)
* `{n:f}`: family name (RFC6350#6.2.2)
* `{n:g}`: given name (RFC6350#6.2.2)
* `{n:a}`: additional name (RFC6350#6.2.2)
* `{nickname}`: nick name (RFC6350#6.2.3)
Supported extra placeholders
* `{age}`: age, toggles to creation of single events instead using RRULE
Fallback is supported if placeholders inside `[...|...]` (first successful resolved one is used)
##### conversion_bday_description_template
_(>= 3.7.5)_
Global template for description of conversion "bday"
Default: `BDAY={year}-{month}-{day}`
Supported placeholders see `conversion_bday_summary_template`
##### conversion_bday_alarm_trigger_template
_(>= 3.7.5)_
Global template for alarm trigger of conversion "bday"
Default: ``
Supported format: `TIMEDELTA;DESCRIPTION` (separated by `|` if more alarms should be generated)
Supported format for `TIMEDELTA`: `[+-]?[0-9]+[WDHM]`
Supported placeholders for `DESCRIPTION` see `conversion_bday_summary_template`
##### conversion_bday_categories
_(>= 3.7.5)_
Global categories of conversion "bday", separated by `,`
Default: `Birthday`
##### conversion_bday_age_max
_(>= 3.7.5)_
Global max limit of "bday" age, only active in case of `{age}` is used as placeholder
Default: `99`
## Supported Clients ## Supported Clients
Radicale has been tested with: Radicale has been tested with:

View File

@@ -43,7 +43,7 @@ Types of supported sharing configuration:
* `TimestampCreated`: unixtime of creation * `TimestampCreated`: unixtime of creation
* `TimestampUpdated`: unixtime of last update * `TimestampUpdated`: unixtime of last update
* `Properties`: overlay properties (limited set whitelisted) * `Properties`: overlay properties (limited set whitelisted)
* `Actions`: (reserved for future usage) * `Actions`: specific configuration
`Enabled*`: _owner_ AND _user_ have to enable a share to become usable `Enabled*`: _owner_ AND _user_ have to enable a share to become usable
@@ -246,6 +246,7 @@ Can be selected by `HTTP_ACCEPT` - default is equal to provided `CONTENT_TYPE`
* `Enabled`: owner/user selected by authentication * `Enabled`: owner/user selected by authentication
* `Hidden`: owner/user selected by authentication * `Hidden`: owner/user selected by authentication
* `Properties`: properties to overlay * `Properties`: properties to overlay
* `Actions`: specific configuration
### API Hooks ### API Hooks
@@ -315,7 +316,8 @@ Create a share by mapping a collection of an `Owner` to a token.
| Permissions | str | optional (default:rp) | | Permissions | str | optional (default:rp) |
| Enabled | bool | optional (owner/default:False) | | Enabled | bool | optional (owner/default:False) |
| Hidden | bool | optional (owner/default:True) | | Hidden | bool | optional (owner/default:True) |
| Properties | str | optional | | Properties | str(dict) | optional |
| Actions | str(dict) | optional |
* Output: text/plain|application/json * Output: text/plain|application/json
@@ -364,7 +366,8 @@ Create a share by mapping a collection of an `Owner` to an `User`.
| Permissions | str | optional (default:r) | | Permissions | str | optional (default:r) |
| Enabled | bool | optional (owner/default:False) | | Enabled | bool | optional (owner/default:False) |
| Hidden | bool | optional (owner/default:True) | | Hidden | bool | optional (owner/default:True) |
| Properties | optional | | Properties | str(dict) | optional |
| Actions | str(dict) | optional |
* Output: text/plain|application/json * Output: text/plain|application/json
@@ -518,7 +521,8 @@ Execute delete+create in case `PathOrToken` needs to be changed.
| Permissions | str | adjust | optional | not-permitted | | Permissions | str | adjust | optional | not-permitted |
| Enabled | bool | adjust | optional(owner) | optional(user) | | Enabled | bool | adjust | optional(owner) | optional(user) |
| Hidden | bool | adjust | optional(owner) | optional(user) | | Hidden | bool | adjust | optional(owner) | optional(user) |
| Properties | str | adjust | optional | optional | | Properties | str(dict) | adjust | optional | optional |
| Actions | str(dict) | adjust | optional | not-permitted |
* Output: text/plain|application/json * Output: text/plain|application/json
@@ -675,7 +679,6 @@ curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-f
Owner can create for itself or for particular user a virtual bday collection from an existing addressbook. Owner can create for itself or for particular user a virtual bday collection from an existing addressbook.
### Examples ### Examples
Preconditions: Preconditions:
@@ -687,6 +690,8 @@ Preconditions:
#### Examples using API #### Examples using API
##### Mapping
* Create as *map* * Create as *map*
```bash ```bash
@@ -712,6 +717,8 @@ END:VCALENDAR
Via WebUI an additional (virtual) calendar collection appears Via WebUI an additional (virtual) calendar collection appears
##### Mapping as token
* Create as *token* * Create as *token*
```bash ```bash
@@ -722,7 +729,7 @@ Status='success'
PathOrToken='/.token/v1/lqqwqhZYTGi9uSPsixien_8G5jiSK0FfhNFRGG_t8UA0/' PathOrToken='/.token/v1/lqqwqhZYTGi9uSPsixien_8G5jiSK0FfhNFRGG_t8UA0/'
``` ```
* Fetch *map* * Fetch *token*
```bash ```bash
## Fetch VCALENDAR auto-created from VADDRESSBOOK ## Fetch VCALENDAR auto-created from VADDRESSBOOK

17
config
View File

@@ -356,6 +356,23 @@
# Supported: rwEePp # Supported: rwEePp
#default_permissions_create_map = r #default_permissions_create_map = r
# Global template for summary of conversion "bday"
#conversion_bday_summary_template = [{n:f} {n:g} |{fn}|{nickname}] ({year}) (BDAY)
# Global template for description of conversion "bday"
#conversion_bday_description_template = BDAY={year}-{month}-{day}
# Global template of alarm trigger of conversion "bday"
# Example: "-15H;BDAY tomorrow|9H;BDAY today"
#conversion_bday_alarm_trigger_template = ""
# Global categories of conversion "bday"
#conversion_bday_categories = Birthday
# Global max limit of "bday" age
# Only active in case of {age} is used as placeholder
#conversion_bday_age_max = 99
[web] [web]

View File

@@ -136,9 +136,9 @@ class ApplicationPartGet(ApplicationBase):
if share and share['Conversion'] == "bday": if share and share['Conversion'] == "bday":
if isinstance(item, storage.BaseCollection): if isinstance(item, storage.BaseCollection):
# convert VCF to ICS # convert VCF to ICS
answer = item.serialize(vcf_to_ics=True) answer = item.serialize(vcf_to_ics=True, ShareActions=share['Actions'])
else: else:
item_converted = item.convert_vcf_to_ics() item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions'])
if item_converted is not None: if item_converted is not None:
answer = item_converted.serialize() answer = item_converted.serialize()
else: else:

View File

@@ -230,7 +230,7 @@ def xml_propfind_response(
element.text = item.etag element.text = item.etag
else: else:
if share_bday_automap: if share_bday_automap:
item_converted = item.convert_vcf_to_ics() item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions'])
if item_converted: if item_converted:
element.text = item_converted.etag element.text = item_converted.etag
else: else:
@@ -382,14 +382,14 @@ def xml_propfind_response(
logger.trace("PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling for collection") logger.trace("PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling for collection")
length = 0 length = 0
for entry in item.get_all(): for entry in item.get_all():
item_ics = entry.convert_vcf_to_ics() item_ics = entry.convert_vcf_to_ics(ShareActions=share['Actions'])
if item_ics is None: if item_ics is None:
continue continue
length += len(item_ics.vobject_item.serialize().encode(encoding)) length += len(item_ics.vobject_item.serialize().encode(encoding))
element.text = str(length) element.text = str(length)
else: else:
logger.trace("PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling for single item") logger.trace("PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling for single item")
item_converted = item.convert_vcf_to_ics() item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions'])
if item_converted is not None: if item_converted is not None:
element.text = str(len(item_converted.serialize())) element.text = str(len(item_converted.serialize()))
else: else:
@@ -457,7 +457,7 @@ def xml_propfind_response(
logger.trace("PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling") logger.trace("PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling")
items = [] items = []
for entry in item.get_all(): for entry in item.get_all():
item_ics = entry.convert_vcf_to_ics() item_ics = entry.convert_vcf_to_ics(ShareActions=share['Actions'])
if item_ics is None: if item_ics is None:
continue continue
items.append(item_ics.vobject_item) items.append(item_ics.vobject_item)
@@ -626,7 +626,7 @@ class ApplicationPartPropfind(ApplicationBase):
for item, permission, raw_permissions in item_list: for item, permission, raw_permissions in item_list:
if self._sharing._enabled and share: if self._sharing._enabled and share:
if share['Conversion'] == "bday" and not isinstance(item, storage.BaseCollection): if share['Conversion'] == "bday" and not isinstance(item, storage.BaseCollection):
if not item.convert_vcf_to_ics(): if not item.convert_vcf_to_ics(ShareActions=share['Actions']):
if len_item_list == 1: if len_item_list == 1:
# only dedicated item requested # only dedicated item requested
return httputils.NOT_FOUND return httputils.NOT_FOUND

View File

@@ -267,7 +267,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
# autoconvert # autoconvert
retrieved_items_vcf_to_ics = [] retrieved_items_vcf_to_ics = []
for item, flag in retrieved_items: for item, flag in retrieved_items:
item_ics = item.convert_vcf_to_ics() item_ics = item.convert_vcf_to_ics(ShareActions=share['Actions'])
if item_ics is None: if item_ics is None:
continue continue
else: else:

View File

@@ -593,7 +593,28 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
("default_permissions_create_map", { ("default_permissions_create_map", {
"value": "r", "value": "r",
"help": "default permissions for map-based sharing", "help": "default permissions for map-based sharing",
"type": rights_permission})])), "type": rights_permission}),
("conversion_bday_summary_template", {
"value": sharing.SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT,
"help": "conversion bday summary template",
"type": sharing.check_template_not_empty}),
("conversion_bday_description_template", {
"value": sharing.SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT,
"help": "conversion bday description template",
"type": sharing.check_template}),
("conversion_bday_alarm_trigger_template", {
"value": "",
"help": "conversion bday alarm trigger template",
"type": sharing.check_template_alarm_trigger}),
("conversion_bday_categories", {
"value": sharing.SHARING_BDAY_CATEGORIES_DEFAULT,
"help": "conversion bday categories",
"type": str}),
("conversion_bday_age_max", {
"value": str(sharing.SHARING_BDAY_AGE_MAX_DEFAULT),
"help": "conversion bday age max",
"type": sharing.check_bday_max_age}),
])),
("hook", OrderedDict([ ("hook", OrderedDict([
("type", { ("type", {
"value": "none", "value": "none",

View File

@@ -38,7 +38,7 @@ from typing import (Any, Callable, List, MutableMapping, Optional, Sequence,
import vobject import vobject
from radicale import storage # noqa:F401 from radicale import storage # noqa:F401
from radicale import pathutils, utils from radicale import pathutils, sharing, utils
from radicale.item import filter as radicale_filter from radicale.item import filter as radicale_filter
from radicale.log import logger from radicale.log import logger
@@ -47,6 +47,8 @@ PRODID_CONVERTED = u"-//Radicale//NONSGML " + utils.package_version("radicale")
PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")" PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")"
UID_SUFFIX = "-auto-converted-by-Radicale" UID_SUFFIX = "-auto-converted-by-Radicale"
VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: list = ["fn", "n:f", "n:g", "n:a", "age", "nickname", "year", "month", "day"]
def read_components(s: str) -> List[vobject.base.Component]: def read_components(s: str) -> List[vobject.base.Component]:
"""Wrapper for vobject.readComponents""" """Wrapper for vobject.readComponents"""
@@ -362,6 +364,65 @@ def verify(file: str, encoding: str):
return True return True
def replace_placeholders(text: str, placeholder_mapping: dict) -> str:
for placeholder in placeholder_mapping:
text = text.replace(placeholder, placeholder_mapping[placeholder])
# resolve {..|..} recursive
pattern = re.compile('(.*)(\\[)([^|]+)\\|(.+)(\\])(.*)')
logger.trace("item/convert_vcf_to_ics: resolve [..|..] starting with: %r", text)
while True:
match = pattern.match(text)
if not match:
# nothing more todo
break
else:
if match[3].startswith('!') and match[3].endswith('!'):
# not resolved variable
if '|' in match[4]:
# further recursion required
text = match[1] + match[2] + match[4] + match[5] + match[6]
logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/continue result: %r", text)
else:
text = match[1] + match[4] + match[6]
logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/final result: %r", text)
break
else:
# resolved variable
text = match[1] + match[3] + match[6]
logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace(resolved) result: %r", text)
return text
def trigger_to_timedelta(trigger) -> Union[datetime.timedelta, None]:
# workaround as vobject is not supporting direct set of value
# limited implementatino of reverse function of timedeltaToString in vobject/icalendar.py
pattern = re.compile('([+-])?([0-9]+)([WDHM])$')
match = pattern.match(trigger)
if not match:
logger.error("item/convert_vcf_to_ics: trigger time value not valid: %r", trigger)
return None
sign = 1
if match[1] == "-":
sign = -1
value = int(match[2]) * sign
td: Union[datetime.timedelta, None] = None
if match[3] == "D":
td = datetime.timedelta(days=value)
elif match[3] == "M":
td = datetime.timedelta(minutes=value)
elif match[3] == "H":
td = datetime.timedelta(hours=value)
elif match[3] == "W":
td = datetime.timedelta(weeks=value)
return td
class Item: class Item:
"""Class for address book and calendar entries.""" """Class for address book and calendar entries."""
@@ -504,7 +565,8 @@ class Item:
self.component_name self.component_name
self._vobject_item = orig_vobject_item self._vobject_item = orig_vobject_item
def convert_vcf_to_ics(self) -> Union["Item", None]: def convert_vcf_to_ics(self, ShareActions: dict = {}) -> Union["Item", None]:
logger.trace("item/convert_vcf_to_ics: ShareActions: %r", ShareActions)
logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href) logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href)
logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (vobject): %r", self.vobject_item) logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (vobject): %r", self.vobject_item)
if self.vobject_item.name != "VCARD": if self.vobject_item.name != "VCARD":
@@ -529,23 +591,54 @@ class Item:
else: else:
pass pass
placeholder_mapping: dict = {}
bdayS = match[1] + match[2] + match[3] bdayS = match[1] + match[2] + match[3]
bdaySdesc = match[1] + "-" + match[2] + "-" + match[3]
bdayY = int(match[1]) bdayY = int(match[1])
bdayM = int(match[2]) bdayM = int(match[2])
bdayD = int(match[3]) bdayD = int(match[3])
placeholder_mapping['{year}'] = match[1]
placeholder_mapping['{month}'] = match[2]
placeholder_mapping['{day}'] = match[3]
# create ICS # create ICS
if hasattr(self.vobject_item, "fn"): if hasattr(self.vobject_item, "fn"):
name = self.vobject_item.fn.value name = self.vobject_item.fn.value
elif hasattr(self.vobject_item, "n"): elif hasattr(self.vobject_item, "n"):
name = self.vobject_item.n.value name = self.vobject_item.n.value.family + " " + self.vobject_item.n.value.given
elif hasattr(self.vobject_item, "nickname"): elif hasattr(self.vobject_item, "nickname"):
name = self.vobject_item.nickname.value name = self.vobject_item.nickname.value
else: else:
logger.trace("item/convert_vcf_to_ics: has bday but neither FN or N or NICKNAME (skip): %r", self.href) logger.trace("item/convert_vcf_to_ics: has bday but neither FN or N or NICKNAME (skip): %r", self.href)
return None return None
if hasattr(self.vobject_item, "nickname") and self.vobject_item.nickname.value != "":
placeholder_mapping['{nickname}'] = self.vobject_item.nickname.value
else:
placeholder_mapping['{nickname}'] = '!nickname!'
if hasattr(self.vobject_item, "fn") and self.vobject_item.fn.value != "":
placeholder_mapping['{fn}'] = self.vobject_item.fn.value
else:
placeholder_mapping['{nickname}'] = '!fn!'
# rfc6350#6.2 FamilyName;GivenName;AdditionalNames;HonorificPrefixes;HonorificSuffixes
if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.family != "":
placeholder_mapping['{n:f}'] = self.vobject_item.n.value.family
else:
placeholder_mapping['{n:f}'] = '!n:f!'
if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.given != "":
placeholder_mapping['{n:g}'] = self.vobject_item.n.value.given
else:
placeholder_mapping['{n:g}'] = '!n:g!'
if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.additional != "":
placeholder_mapping['{n:a}'] = self.vobject_item.n.value.additional
else:
placeholder_mapping['{n:a}'] = '!n:a!'
# create VCALENDAR # create VCALENDAR
item_ics = vobject.newFromBehavior('vcalendar') item_ics = vobject.newFromBehavior('vcalendar')
@@ -555,40 +648,134 @@ class Item:
else: else:
item_ics.add('prodid').value = PRODID_CONVERTED item_ics.add('prodid').value = PRODID_CONVERTED
# create EVENT # prepare SUMMARY
item_ics.add('vevent') if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_summary_template' in ShareActions['config']:
summary = ShareActions['config']['conversion_bday_summary_template']
elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_summary_template' in ShareActions['config_default']:
summary = ShareActions['config_default']['conversion_bday_summary_template']
else:
summary = sharing.SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT # fallback
summary = replace_placeholders(summary, placeholder_mapping)
# set DTSTART # prepare DESCRIPTION
dtstart = datetime.date(bdayY, bdayM, bdayD) if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_description_template' in ShareActions['config']:
item_ics.vevent.add('dtstart').value = dtstart description = ShareActions['config']['conversion_bday_description_template']
elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_description_template' in ShareActions['config_default']:
description = ShareActions['config_default']['conversion_bday_description_template']
else:
description = sharing.SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT # fallback
description = replace_placeholders(description, placeholder_mapping)
# calculate and set DTEND # create CATEGORIES
dtend = dtstart + datetime.timedelta(days=1) if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_categories' in ShareActions['config']:
item_ics.vevent.add('dtend').value = dtend categories = ShareActions['config']['conversion_bday_categories'].split(',')
elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_categories' in ShareActions['config_default']:
categories = ShareActions['config_default']['conversion_bday_categories'].split(',')
else:
categories = sharing.SHARING_BDAY_CATEGORIES_DEFAULT.split(',') # fallback
# set UID # check ALARM
if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_alarm_trigger_template' in ShareActions['config']:
alarm_trigger = ShareActions['config']['conversion_bday_alarm_trigger_template']
elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_alarm_trigger_template' in ShareActions['config_default']:
alarm_trigger = ShareActions['config_default']['conversion_bday_alarm_trigger_template']
else:
alarm_trigger = "" # default
vevent_enable_age = False
age_max = 0
if "{age}" in summary or "{age}" in description or "age" in alarm_trigger:
if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_age_max' in ShareActions['config']:
age_max = ShareActions['config']['conversion_bday_age_max']
elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_age_max' in ShareActions['config_default']:
age_max = ShareActions['config_default']['conversion_bday_age_max']
else:
age_max = sharing.SHARING_BDAY_AGE_MAX_DEFAULT # fallback
vevent_enable_age = True
# create UID
if hasattr(self.vobject_item, "uid"): if hasattr(self.vobject_item, "uid"):
pattern = re.compile('^(.*)-[0-9a-fA-F]{12}(.*)$') pattern = re.compile('^(.*)-[0-9a-fA-F]{12}(.*)$')
match = pattern.match(self.vobject_item.uid.value) match = pattern.match(self.vobject_item.uid.value)
if match: if match:
# replace part of UUID by bday # replace part of UUID by bday
item_ics.vevent.add('uid').value = match[1] + '-' + 'bda0' + bdayS + match[2] uid = match[1] + '-' + 'bda0' + bdayS + match[2]
else: else:
item_ics.vevent.add('uid').value = self.vobject_item.uid.value + UID_SUFFIX uid = self.vobject_item.uid.value + UID_SUFFIX
else: else:
item_ics.vevent.add('uid').value = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX uid = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX
# set SUMMARY age = 0
item_ics.vevent.add('summary').value = name + " (BDAY)" while age <= age_max:
# create EVENT
vevent = item_ics.add('vevent')
# set RRULE # set DTSTART
item_ics.vevent.add('rrule').value = "FREQ=YEARLY" if vevent_enable_age:
dtstart = datetime.date(bdayY + age, bdayM, bdayD)
else:
dtstart = datetime.date(bdayY, bdayM, bdayD)
vevent.add('dtstart').value = dtstart
# add transparency # calculate and set DTEND
item_ics.vevent.add('transp').value = "TRANSPARENT" dtend = dtstart + datetime.timedelta(days=1)
vevent.add('dtend').value = dtend
# add description # set UID
item_ics.vevent.add('description').value = "BDAY=" + bdaySdesc if vevent_enable_age:
uid_value = uid + "-AGE-" + str(age)
else:
uid_value = uid
vevent.add('uid').value = uid_value
# set SUMMARY
if vevent_enable_age:
summary_value = summary.replace("{age}", str(age))
else:
summary_value = summary
vevent.add('summary').value = summary_value
# set CATEGORIES
if categories is not None and categories != []:
vevent.add('categories').value = categories
# set CLASS
vevent.add('class').value = "PRIVATE"
# set STATUS
vevent.add('status').value = "CONFIRMED"
# set VALARM
if alarm_trigger is not None and alarm_trigger != "":
for entry in alarm_trigger.split('|'):
(trigger, alarm_description) = entry.split(';')
logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, description)
td = trigger_to_timedelta(trigger)
if td is not None:
alarm_description = replace_placeholders(alarm_description, placeholder_mapping)
alarm_description_value = alarm_description.replace("{age}", str(age))
valarm = vevent.add('valarm')
valarm.add('action').value = "DISPLAY"
valarm.add('description').value = alarm_description_value
valarm.add('trigger').value = td
# set RRULE
if not vevent_enable_age:
vevent.add('rrule').value = "FREQ=YEARLY"
# add transparency
vevent.add('transp').value = "TRANSPARENT"
# set DESCRIPTION
if vevent_enable_age:
description_value = description.replace("{age}", str(age))
else:
description_value = description
if description != "":
vevent.add('description').value = description_value
# increase age
age = age + 1
href = self.href href = self.href
if href is not None: if href is not None:

View File

@@ -24,11 +24,11 @@ import uuid
from csv import DictWriter from csv import DictWriter
from datetime import datetime from datetime import datetime
from http import client from http import client
from typing import Sequence, Union from typing import Any, Sequence, Union
from urllib.parse import parse_qs from urllib.parse import parse_qs
from radicale import (config, httputils, pathutils, rights, storage, types, from radicale import (config, httputils, item, pathutils, rights, storage,
utils) types, utils)
from radicale.log import logger from radicale.log import logger
INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none")
@@ -123,6 +123,77 @@ TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}"
OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order") OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order")
SHARING_BDAY_AGE_MAX_LIMIT: int = 199 # maximum age to prevent unexpected DoS by config
SHARING_BDAY_AGE_MAX_DEFAULT: int = 99
SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT: str = "[{n:f} {n:g}|{fn}|{nickname}] ({year}) (BDAY)"
SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT: str = "BDAY={year}-{month}-{day}"
SHARING_BDAY_CATEGORIES_DEFAULT: str = 'Birthday'
def check_bday_max_age(data: Any) -> int:
value = int(data)
if value < 0:
raise ValueError("value is negative: %d" % value)
if value > SHARING_BDAY_AGE_MAX_LIMIT:
raise ValueError("value exceeds maximum (%d): %d" % (SHARING_BDAY_AGE_MAX_LIMIT, value))
return value
def check_template(data: Any) -> str:
placeholder_mapping: dict = {}
for placeholder in item.VCF_TO_ICS_SUPPORTED_PLACEHOLDERS:
placeholder_mapping["{" + placeholder + "}"] = '!' + placeholder + '!'
result = item.replace_placeholders(data, placeholder_mapping)
logger.trace("replace placeholders: %r -> %r", data, result)
pattern = re.compile('.*{.*}.*')
if pattern.search(result):
raise ValueError("template contains unsupported placeholder {..}: %r" % result)
return data
def check_template_not_empty(data: Any) -> str:
result = check_template(data)
if result == "":
raise ValueError("template not allowed to be empty")
return data
def check_template_alarm_trigger(data: Any) -> str:
if data is not None and data != '':
for entry in data.split('|'):
try:
(trigger, alarm_description) = entry.split(';')
except ValueError:
raise ValueError("alarm trigger template misses <trigger>;<alarm description>")
if trigger is not None and trigger != '':
td = item.trigger_to_timedelta(trigger)
if td is None:
raise ValueError("alarm trigger template contains unsupported trigger: %r" % trigger)
else:
raise ValueError("alarm trigger template misses trigger")
if alarm_description is not None and alarm_description != '':
try:
check_template_not_empty(alarm_description)
except Exception as e:
raise e
else:
raise ValueError("alarm trigger template misses description")
return data
ACTIONS_WHITELIST: dict = {
'config': {
'conversion_bday_summary_template': check_template_not_empty,
'conversion_bday_description_template': check_template,
'conversion_bday_alarm_trigger_template': check_template_alarm_trigger,
'conversion_bday_categories': str,
'conversion_bday_age_max': check_bday_max_age,
},
}
CONVERSIONS_WHITELIST: Sequence[str] = ("bday", "none") CONVERSIONS_WHITELIST: Sequence[str] = ("bday", "none")
@@ -166,6 +237,11 @@ class BaseSharing:
self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map") self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map")
self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay") self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay")
self.enforce_properties_overlay = configuration.get("sharing", "enforce_properties_overlay") self.enforce_properties_overlay = configuration.get("sharing", "enforce_properties_overlay")
self.conversion_bday_summary_template = configuration.get("sharing", "conversion_bday_summary_template")
self.conversion_bday_description_template = configuration.get("sharing", "conversion_bday_description_template")
self.conversion_bday_alarm_trigger_template = configuration.get("sharing", "conversion_bday_alarm_trigger_template")
self.conversion_bday_categories = configuration.get("sharing", "conversion_bday_categories")
self.conversion_bday_age_max = configuration.get("sharing", "conversion_bday_age_max")
logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map)
logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token)
@@ -175,6 +251,11 @@ class BaseSharing:
logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map) logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map)
logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay) logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay)
logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay) logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay)
logger.info("sharing.conversion_bday_summary_template: %r", self.conversion_bday_summary_template)
logger.info("sharing.conversion_bday_description_template: %r", self.conversion_bday_description_template)
logger.info("sharing.conversion_bday_alarm_trigger_template: %r", self.conversion_bday_alarm_trigger_template)
logger.info("sharing.conversion_bday_categories: %r", self.conversion_bday_categories)
logger.info("sharing.conversion_bday_age_max: %s", self.conversion_bday_age_max)
# database tasks # database tasks
self.sharing_db_type = configuration.get("sharing", "type") self.sharing_db_type = configuration.get("sharing", "type")
@@ -403,6 +484,73 @@ class BaseSharing:
else: else:
logger.trace("sharing/map: not active") logger.trace("sharing/map: not active")
if share is not None:
if share['Conversion'] == "bday":
# autogenerate Actions if not existing
if share['Actions'] is None or 'config' not in share['Actions']:
share['Actions'] = {
'config_default': {
'conversion_bday_summary_template': self.conversion_bday_summary_template,
'conversion_bday_description_template': self.conversion_bday_description_template,
'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template,
'conversion_bday_categories': self.conversion_bday_categories,
'conversion_bday_age_max': self.conversion_bday_age_max,
},
}
else:
if 'config' in share['Actions']:
if 'conversion_bday_summary_template' in share['Actions']['config']:
# nothing to do
pass
else:
if 'config_default' not in share['Actions']:
share['Actions'].update({'config_default': {}})
share['Actions']['config_default'].update(
{'conversion_bday_summary_template': self.conversion_bday_summary_template}
)
if 'conversion_bday_description_template' in share['Actions']['config']:
# nothing to do
pass
else:
if 'config_default' not in share['Actions']:
share['Actions'].update({'config_default': {}})
share['Actions']['config_default'].update(
{'conversion_bday_description_template': self.conversion_bday_description_template}
)
if 'conversion_bday_alarm_trigger_template' in share['Actions']['config']:
# nothing to do
pass
else:
if 'config_default' not in share['Actions']:
share['Actions'].update({'config_default': {}})
share['Actions']['config_default'].update(
{'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template}
)
if 'conversion_bday_categories' in share['Actions']['config']:
# nothing to do
pass
else:
if 'config_default' not in share['Actions']:
share['Actions'].update({'config_default': {}})
share['Actions']['config_default'].update(
{'conversion_bday_categories': self.conversion_bday_categories}
)
if 'conversion_bday_age_max' in share['Actions']['config']:
# nothing to do
pass
else:
if 'config_default' not in share['Actions']:
share['Actions'].update({'config_default': {}})
share['Actions']['config_default'].update(
{'conversion_bday_age_max': self.conversion_bday_age_max}
)
logger.info("sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r Actions=%r", share['ShareType'], share['PathOrToken'], share['PathMapped'], user, share['Owner'], share['Permissions'], share['Conversion'], share['Actions'])
return share return share
# adjust a share # adjust a share
@@ -789,7 +937,31 @@ class BaseSharing:
return httputils.bad_request("Conversion not supported: %r" % Conversion) return httputils.bad_request("Conversion not supported: %r" % Conversion)
if 'Actions' in request_data: if 'Actions' in request_data:
return httputils.bad_request("Actions currently not supported (reserved for future needs)") valid = True # default
hint = ""
for level1 in request_data['Actions']:
if level1 in ACTIONS_WHITELIST:
for level2 in request_data['Actions'][level1]:
if level2 in ACTIONS_WHITELIST[level1]:
if callable(ACTIONS_WHITELIST[level1][level2]):
try:
value = ACTIONS_WHITELIST[level1][level2](request_data['Actions'][level1][level2])
except ValueError:
hint = "'" + level1 + "': {'" + level2 + "'} is not supported"
valid = False
break
pass
else:
hint = "'" + level1 + "': {'" + level2 + "'} is not supported"
valid = False
break
else:
hint = "'" + level1 + "' is not supported"
valid = False
break
if not valid:
return httputils.bad_request("Actions format not valid: " + hint)
Actions = request_data['Actions']
if 'Enabled' in request_data: if 'Enabled' in request_data:
Enabled = request_data['Enabled'] Enabled = request_data['Enabled']
@@ -1112,7 +1284,8 @@ class BaseSharing:
OwnerOrUser=user, OwnerOrUser=user,
User=User, User=User,
Timestamp=Timestamp, Timestamp=Timestamp,
Properties=Properties) Properties=Properties,
Actions=Actions)
else: else:
result = self.database_update_sharing( result = self.database_update_sharing(
ShareType=ShareType, ShareType=ShareType,
@@ -1124,7 +1297,8 @@ class BaseSharing:
OwnerOrUser=user, OwnerOrUser=user,
User=User, User=User,
Timestamp=Timestamp, Timestamp=Timestamp,
Properties=Properties) Properties=Properties,
Actions=Actions)
elif user == share['User']: elif user == share['User']:
# User is only allowed to update Properties # User is only allowed to update Properties

View File

@@ -441,6 +441,8 @@ class Sharing(sharing.BaseSharing):
field = field.replace("{'", '{"') # replace for JSON start {' -> {" field = field.replace("{'", '{"') # replace for JSON start {' -> {"
field = field.replace("'}", '"}') # replace for JSON end '} -> "} field = field.replace("'}", '"}') # replace for JSON end '} -> "}
field = field.replace("': '", '": "') # replace for JSON entry/value ': ' -> ": " field = field.replace("': '", '": "') # replace for JSON entry/value ': ' -> ": "
field = field.replace("': {", '": {') # replace for JSON entry/value ': { -> ": {
field = field.replace("': ", '": ') # replace for JSON entry/value ': -> ":(whitespace)
field = field.replace("', '", '", "') # replace for JSON delimiter ', ' -> ", " field = field.replace("', '", '", "') # replace for JSON delimiter ', ' -> ", "
logger.trace("json prep quote replacer match (after) : %s", field) logger.trace("json prep quote replacer match (after) : %s", field)
try: try:

View File

@@ -223,7 +223,7 @@ class BaseCollection:
"""Get the HTTP-datetime of when the collection was modified.""" """Get the HTTP-datetime of when the collection was modified."""
raise NotImplementedError raise NotImplementedError
def serialize(self, vcf_to_ics: bool = False) -> str: def serialize(self, vcf_to_ics: bool = False, ShareActions: dict = {}) -> str:
"""Get the unicode string representing the whole collection.""" """Get the unicode string representing the whole collection."""
if self.tag == "VCALENDAR": if self.tag == "VCALENDAR":
in_vcalendar = False in_vcalendar = False
@@ -288,7 +288,7 @@ class BaseCollection:
items = [] items = []
for item in self.get_all(): for item in self.get_all():
logger.trace("storage/convert VCF to ICS: %r:", item) logger.trace("storage/convert VCF to ICS: %r:", item)
item_ics = item.convert_vcf_to_ics() item_ics = item.convert_vcf_to_ics(ShareActions=ShareActions)
if item_ics is None: if item_ics is None:
continue continue
else: else:

View File

@@ -2,7 +2,7 @@ BEGIN:VCARD
VERSION:3.0 VERSION:3.0
PRODID:+//IDN bitfire.at//DAVx5/3.3.5-ose ez-vcard/0.11.0 PRODID:+//IDN bitfire.at//DAVx5/3.3.5-ose ez-vcard/0.11.0
UID:contact2-with-bday UID:contact2-with-bday
N:Test;N;;; N:FamilyTest;GivenTest;AdditionalsTest;;
FN:Test-FN FN:Test-FN
NICKNAME:Test-NICKNAME NICKNAME:Test-NICKNAME
BDAY:1970-01-01 BDAY:1970-01-01

View File

@@ -2,8 +2,7 @@ BEGIN:VCARD
VERSION:3.0 VERSION:3.0
PRODID:+//Manual/0.0.1 PRODID:+//Manual/0.0.1
UID:05cf4901-e581-44ee-bb0a-1e1a875da44a UID:05cf4901-e581-44ee-bb0a-1e1a875da44a
N:Test;N;C3;; N:Family3Test;Given3Test;;;
FN:Test-FN-C3 FN:Test-FN-C3
NICKNAME:Test-NICKNAME-C3
BDAY:1990-01-01 BDAY:1990-01-01
END:VCARD END:VCARD

View File

@@ -301,7 +301,7 @@ class TestBaseAuthRequests(BaseTest):
delay = .3 delay = .3
delay_min = delay * 0.9 # no random jitter during test delay_min = delay * 0.9 # no random jitter during test
delay_max = delay + 0.2 # no random jitter during test delay_max = delay + 0.2 # no random jitter during test
if sys.platform == "darwin": # no reliable sleep times if sys.platform == "darwin" or sys.platform == 'win32': # no reliable sleep times
delay_max = delay_max * 1.5 delay_max = delay_max * 1.5
time_begin = datetime.datetime.now() time_begin = datetime.datetime.now()

View File

@@ -4830,6 +4830,7 @@ permissions: RrWw""")
assert "DTEND;VALUE=DATE:19700102" in answer assert "DTEND;VALUE=DATE:19700102" in answer
assert "TRANSP:TRANSPARENT" in answer assert "TRANSP:TRANSPARENT" in answer
assert "DESCRIPTION:BDAY=1970-01-01" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer
assert "CATEGORIES:Birthday" in answer
# content type must be adjusted # content type must be adjusted
assert 'Content-Type' in headers assert 'Content-Type' in headers
assert 'text/calendar' in headers['Content-Type'] assert 'text/calendar' in headers['Content-Type']
@@ -4879,6 +4880,9 @@ permissions: RrWw""")
assert "DTEND;VALUE=DATE:19700102" in answer assert "DTEND;VALUE=DATE:19700102" in answer
assert "TRANSP:TRANSPARENT" in answer assert "TRANSP:TRANSPARENT" in answer
assert "DESCRIPTION:BDAY=1970-01-01" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer
assert "CATEGORIES:Birthday" in answer
assert "CLASS:PRIVATE" in answer
assert "STATUS:CONFIRMED" in answer
# content type must be adjusted # content type must be adjusted
assert 'Content-Type' in headers assert 'Content-Type' in headers
assert 'text/calendar' in headers['Content-Type'] assert 'text/calendar' in headers['Content-Type']
@@ -4931,6 +4935,635 @@ permissions: RrWw""")
status, prop = response["D:getcontenttype"] status, prop = response["D:getcontenttype"]
assert "text/calendar" in str(prop.text) assert "text/calendar" in str(prop.text)
def test_sharing_api_map_vcf_bday_template(self) -> None:
"""share-by-map with conversion=bday template tests."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_map": True,
"permit_properties_overlay": "True",
"enforce_properties_overlay": "True",
"collection_by_map": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True",
"response_header_on_debug": "True",
"request_content_on_debug": "True"},
"rights": {"type": "owner_only"}})
json_dict: dict
logging.info("\n*** prepare and test access")
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
logging.info("\n*** test: %s", db_type)
self.configure({"sharing": {"type": db_type}})
path_mapped = "/owner/adressbook-" + db_type + ".vcf/"
path_shared_r = "/user/calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/"
self.create_addressbook(path_mapped, login="owner:ownerpw")
contact2 = get_file_content("contact2-with-bday.vcf")
path2 = path_mapped + "/contact2-with-bday.vcf"
path_shared_2 = path_shared_r + "/contact2-with-bday.vcf"
self.put(path2, contact2, login="owner:ownerpw")
contact3 = get_file_content("contact3-with-bday.vcf")
path3 = path_mapped + "/contact3-with-bday.vcf"
path_shared_3 = path_shared_r + "/contact3-with-bday.vcf"
self.put(path3, contact3, login="owner:ownerpw")
# create map
logging.info("\n*** create map(bday) user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Conversion'] = "bday"
json_dict['Permissions'] = "rP"
json_dict['Enabled'] = True
json_dict['Enabled'] = True
json_dict['Hidden'] = False
json_dict['Properties'] = {"D:displayname": "Test-BDAY"}
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
# enable map by user
logging.info("\n*** enable map(bday) by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
self.configure({"sharing": {
"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)",
"conversion_bday_description_template": "BDAY={year}-{month}-{day}",
"conversion_bday_alarm_trigger_template": "-15H;BDAY tomorrow|9H;BDAY today",
}})
# verify content as user
logging.info("\n*** GET collection user format:default -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (BDAY)" in answer
self.configure({"sharing": {"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (Birthday)"}})
logging.info("\n*** GET collection user format with fn+Birthday -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (Birthday)" in answer
self.configure({"sharing": {"conversion_bday_summary_template": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (Birthday)"}})
logging.info("\n*** GET collection user format:n -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:FamilyTest GivenTest AdditionalsTest (Birthday)" in answer
self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{n:f} {n:g} {n:a}|{fn}] (Birthday)"}})
logging.info("\n*** GET collection user format:nickname -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-NICKNAME (Birthday)" in answer
self.configure({"sharing": {"conversion_bday_summary_template": "{nickname} (Birthday)"}})
logging.info("\n*** GET collection user format:nickname not resolvable -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "SUMMARY:!nickname! (Birthday)" in answer
self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{fn}] (Birthday)"}})
logging.info("\n*** GET collection user format:nickname with fn fallback -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "SUMMARY:Test-FN-C3 (Birthday)" in answer
self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:g} {n:f}] (Birthday)"}})
logging.info("\n*** GET collection user format:nickname with n fallback -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "SUMMARY:Given3Test Family3Test (Birthday)" in answer
self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f}, {n:g}] (Birthday)"}})
logging.info("\n*** GET collection user format:nickname with n fallback -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "SUMMARY:Family3Test\\, Given3Test (Birthday)" in answer
self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f} {n:g} {n:a}] (Birthday)"}})
logging.info("\n*** GET collection user format:nickname with n fallback -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "SUMMARY:Family3Test Given3Test !n:a! (Birthday)" in answer
assert "DESCRIPTION:BDAY=1990-01-01" in answer
self.configure({"sharing": {"conversion_bday_description_template": "Birthday={year}{month}{day}"}})
logging.info("\n*** GET collection user format: description -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "DESCRIPTION:Birthday=19900101" in answer
self.configure({"sharing": {"conversion_bday_description_template": "year={year} month={month} day={day}\nfn='{fn}'\nn:g='{n:g}'\nn:f='{n:f}'\nn:a='{n:a}'"}})
logging.info("\n*** GET collection user format: description -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "DESCRIPTION:year=1990 month=01 day=01" in answer
assert "DESCRIPTION:BDAY tomorrow" in answer
assert "DESCRIPTION:BDAY today" in answer
assert "TRIGGER:-PT15H" in answer
assert "TRIGGER:PT9H" in answer
self.configure({"sharing": {"conversion_bday_alarm_trigger_template": "-12H;Birthday tomorrow of {fn}|12H;Birthday today of {n:g} {n:f}"}})
logging.info("\n*** GET collection user format: description -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "DESCRIPTION:Birthday tomorrow of Test-FN-C3" in answer
assert "DESCRIPTION:Birthday today of Given3Test Family3Test" in answer
assert "TRIGGER:-PT12H" in answer
assert "TRIGGER:PT12H" in answer
self.configure({"sharing": {"conversion_bday_categories": "Birthday,Geburtstag"}})
logging.info("\n*** GET collection user format: description -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "DESCRIPTION:Birthday tomorrow of Test-FN-C3" in answer
assert "DESCRIPTION:Birthday today of Given3Test Family3Test" in answer
assert "CATEGORIES:Birthday,Geburtstag" in answer
self.configure({
"sharing": {"conversion_bday_description_template": "",
"conversion_bday_alarm_trigger_template": "",
}
})
logging.info("\n*** GET collection user format: no description -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "DESCRIPTION" not in answer
logging.info("\n*** configuration test: conversion_bday_summary_template not supported")
try:
self.configure({"sharing": {
"conversion_bday_description_template": "year={year} month={month} day={day}\nfn='{fn}'\nn:g='{n:g}'\nn:f='{n:f}'\nn:a='{notsupported}'",
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_description_template not supported")
try:
self.configure({"sharing": {
"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f} {n:g} {n:a}] {notsupportedplaceholder}",
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_summary_template empty")
try:
self.configure({"sharing": {
"conversion_bday_summary_template": "",
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_description_template empty")
try:
self.configure({"sharing": {
"conversion_bday_description_template": "",
}})
except RuntimeError:
pass
else:
pass
logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported")
try:
self.configure({"sharing": {
"conversion_bday_alarm_trigger_template": "-12T;Birthday tomorrow of {fn}"
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported")
try:
self.configure({"sharing": {
"conversion_bday_alarm_trigger_template": "BROKEN;Birthday tomorrow of {fn}"
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported")
try:
self.configure({"sharing": {
"conversion_bday_alarm_trigger_template": "-+BROKEN;Birthday tomorrow of {fn}"
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported")
try:
self.configure({"sharing": {
"conversion_bday_alarm_trigger_template": "+0;Birthday tomorrow of {fn}"
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported")
try:
self.configure({"sharing": {
"conversion_bday_alarm_trigger_template": "-12H"
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported")
try:
self.configure({"sharing": {
"conversion_bday_alarm_trigger_template": "-12H;"
}})
except RuntimeError:
pass
else:
raise
# update template
logging.info("\n*** update map(bday) user/owner:r with invalid config -> 400")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Actions'] = {"config": {
"conversion_bday_alarm_trigger_template": "-12H;"
}}
_, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict)
def test_sharing_api_map_vcf_bday_age_template(self) -> None:
"""share-by-map with conversion=bday template tests with age."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_map": True,
"permit_properties_overlay": "True",
"enforce_properties_overlay": "True",
"collection_by_map": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True",
"response_header_on_debug": "True",
"request_content_on_debug": "True"},
"rights": {"type": "owner_only"}})
json_dict: dict
logging.info("\n*** prepare and test access")
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
logging.info("\n*** test: %s", db_type)
self.configure({"sharing": {"type": db_type}})
path_mapped = "/owner/adressbook-" + db_type + ".vcf/"
path_shared_r = "/user/calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/"
self.create_addressbook(path_mapped, login="owner:ownerpw")
contact2 = get_file_content("contact2-with-bday.vcf")
path2 = path_mapped + "/contact2-with-bday.vcf"
path_shared_2 = path_shared_r + "/contact2-with-bday.vcf"
self.put(path2, contact2, login="owner:ownerpw")
# create map
logging.info("\n*** create map(bday) user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Conversion'] = "bday"
json_dict['Permissions'] = "rP"
json_dict['Enabled'] = True
json_dict['Enabled'] = True
json_dict['Hidden'] = False
json_dict['Properties'] = {"D:displayname": "Test-BDAY"}
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
# enable map by user
logging.info("\n*** enable map(bday) by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
self.configure({"sharing": {
"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)",
"conversion_bday_description_template": "BDAY={year}-{month}-{day}",
}})
logging.info("\n*** configuration test: conversion_bday_age_max < 0")
try:
self.configure({"sharing": {
"conversion_bday_age_max": -1,
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** configuration test: conversion_bday_age_max > MAX")
try:
self.configure({"sharing": {
"conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX_LIMIT + 1),
}})
except RuntimeError:
pass
else:
raise
# verify content as user
logging.info("\n*** GET collection user format:default -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (BDAY)" in answer
self.configure({"sharing": {
"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] ({age}. Birthday)",
}})
logging.info("\n*** GET collection user format:text -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (0. Birthday)" in answer
assert "SUMMARY:Test-FN (1. Birthday)" in answer
assert "SUMMARY:Test-FN (99. Birthday)" in answer
assert "SUMMARY:Test-FN (100. Birthday)" not in answer
self.configure({"sharing": {
"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)",
"conversion_bday_description_template": "AGE={age}",
}})
logging.info("\n*** GET collection user format:text -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (BDAY)" in answer
assert "DESCRIPTION:AGE=0" in answer
assert "DESCRIPTION:AGE=1" in answer
assert "DESCRIPTION:AGE=99" in answer
assert "DESCRIPTION:AGE=100" not in answer
self.configure({"sharing": {
"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)",
"conversion_bday_description_template": "BDAY={year}-{month}-{day}",
"conversion_bday_alarm_trigger_template": "-15H;alarm {fn} {age}. birthday",
}})
logging.info("\n*** GET collection user format:text -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (BDAY)" in answer
assert "DESCRIPTION:BDAY=1970-01-01" in answer
assert "DESCRIPTION:alarm Test-FN 0. birthday" in answer
assert "DESCRIPTION:alarm Test-FN 1. birthday" in answer
assert "DESCRIPTION:alarm Test-FN 99. birthday" in answer
assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer
# update template
logging.info("\n*** update map(bday) user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Actions'] = {"config": {
"conversion_bday_age_max": 5,
}}
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
logging.info("\n*** GET collection user format:text -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (BDAY)" in answer
assert "DESCRIPTION:BDAY=1970-01-01" in answer
assert "DESCRIPTION:alarm Test-FN 0. birthday" in answer
assert "DESCRIPTION:alarm Test-FN 1. birthday" in answer
assert "DESCRIPTION:alarm Test-FN 5. birthday" in answer
assert "DESCRIPTION:alarm Test-FN 6. birthday" not in answer
assert "DESCRIPTION:alarm Test-FN 99. birthday" not in answer
assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer
self.configure({"sharing": {
"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)",
"conversion_bday_description_template": "AGE={age}",
"conversion_bday_alarm_trigger_template": "",
}})
logging.info("\n*** GET collection user limit age to 5 -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (BDAY)" in answer
assert "DESCRIPTION:AGE=0" in answer
assert "DESCRIPTION:AGE=1" in answer
assert "DESCRIPTION:AGE=5" in answer
assert "DESCRIPTION:AGE=6" not in answer
assert "DESCRIPTION:AGE=99" not in answer
assert "DESCRIPTION:AGE=100" not in answer
# update template with invalid data test
logging.info("\n*** update map(bday) user/owner:r -> age_max not negative")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Actions'] = {"config": {
"conversion_bday_age_max": -1,
}}
_, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict)
# update template with invalid data test
logging.info("\n*** update map(bday) user/owner:r -> age_max exceeds MAX")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Actions'] = {"config": {
"conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX_LIMIT + 1),
}}
_, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict)
# update template with valid data test
logging.info("\n*** update map(bday) user/owner:r -> age_max ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Actions'] = {"config": {
"conversion_bday_age_max": 0,
}}
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
# update template with valid data test
logging.info("\n*** update map(bday) user/owner:r -> age_max < MAX")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Actions'] = {"config": {
"conversion_bday_age_max": sharing.SHARING_BDAY_AGE_MAX_LIMIT,
}}
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
logging.info("\n*** update map(bday) user/owner:r -> unsupported level 1")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Actions'] = {"level1": {
"level2": "test",
}}
_, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict)
logging.info("\n*** update map(bday) user/owner:r -> unsupported level 2")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Actions'] = {"config": {
"level2": "test",
}}
_, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict)
def test_sharing_api_map_vcf_bday_per_share_template(self) -> None:
"""share-by-map with conversion=bday template per share tests."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_map": True,
"permit_properties_overlay": "True",
"enforce_properties_overlay": "True",
"collection_by_map": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True",
"response_header_on_debug": "True",
"request_content_on_debug": "True"},
"rights": {"type": "owner_only"}})
json_dict: dict
logging.info("\n*** prepare and test access")
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
logging.info("\n*** test: %s", db_type)
self.configure({"sharing": {"type": db_type}})
path_mapped_2 = "/owner/adressbook2-" + db_type + ".vcf/"
path_mapped_3 = "/owner/adressbook3-" + db_type + ".vcf/"
path_shared_2r = "/user/calendar-bday-abook2-shared-by-owner-r-" + db_type + ".ics/"
path_shared_3r = "/user/calendar-bday-abook3-shared-by-owner-r-" + db_type + ".ics/"
self.create_addressbook(path_mapped_2, login="owner:ownerpw")
self.create_addressbook(path_mapped_3, login="owner:ownerpw")
contact2 = get_file_content("contact2-with-bday.vcf")
path2 = path_mapped_2 + "/contact2-with-bday.vcf"
path_shared_2 = path_shared_2r + "/contact2-with-bday.vcf"
self.put(path2, contact2, login="owner:ownerpw")
contact3 = get_file_content("contact3-with-bday.vcf")
path3 = path_mapped_3 + "/contact3-with-bday.vcf"
path_shared_3 = path_shared_3r + "/contact3-with-bday.vcf"
self.put(path3, contact3, login="owner:ownerpw")
# create map
logging.info("\n*** create map(bday) user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped_2
json_dict['PathOrToken'] = path_shared_2r
json_dict['Conversion'] = "bday"
json_dict['Permissions'] = "rP"
json_dict['Enabled'] = True
json_dict['Enabled'] = True
json_dict['Hidden'] = False
json_dict['Properties'] = {"D:displayname": "Test-BDAY2"}
json_dict['Actions'] = {"config": {
"conversion_bday_summary_template": "{fn} (BDAY2)",
"conversion_bday_description_template": "BDAY2={year}-{month}-{day}",
}}
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
logging.info("\n*** create map(bday) user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped_3
json_dict['PathOrToken'] = path_shared_3r
json_dict['Conversion'] = "bday"
json_dict['Permissions'] = "rP"
json_dict['Enabled'] = True
json_dict['Enabled'] = True
json_dict['Hidden'] = False
json_dict['Properties'] = {"D:displayname": "Test-Birthday3"}
json_dict['Actions'] = {"config": {
"conversion_bday_summary_template": "{fn} (Birthday3)",
"conversion_bday_description_template": "Birthday3={year}-{month}-{day}",
}}
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
# enable map by user
logging.info("\n*** enable map(bday) by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped_2
json_dict['PathOrToken'] = path_shared_2r
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
logging.info("\n*** enable map(bday) by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped_3
json_dict['PathOrToken'] = path_shared_3r
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
# verify content as user
logging.info("\n*** GET collection user template 2 -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (BDAY2)" in answer
logging.info("\n*** GET collection user template 3 -> ok")
_, headers, answer = self.request("GET", path_shared_3, login="user:userpw")
assert "SUMMARY:Test-FN-C3 (Birthday3)" in answer
# update template
logging.info("\n*** update map(bday) user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped_2
json_dict['PathOrToken'] = path_shared_2r
json_dict['Actions'] = {"config": {
"conversion_bday_summary_template": "{fn} (BDAY2x)",
"conversion_bday_description_template": "BDAY2x={year}-{month}-{day}",
}}
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
# verify content as user
logging.info("\n*** GET collection user template 2 -> ok")
_, headers, answer = self.request("GET", path_shared_2, login="user:userpw")
assert "SUMMARY:Test-FN (BDAY2x)" in answer
# update template
logging.info("\n*** update map(bday) user/owner:r with wrong Action -> problem")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped_2
json_dict['PathOrToken'] = path_shared_2r
json_dict['Actions'] = {"config": {
"conversion_bday_summary_template_UNSUPPORTED": "{fn} (BDAY2x)",
"conversion_bday_description_template_UNSUPPORTED": "BDAY2x={year}-{month}-{day}",
}}
_, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict)
def test_sharing_api_map_vcf_bday_complex(self) -> None: def test_sharing_api_map_vcf_bday_complex(self) -> None:
"""share-by-map with conversion=bday complex tests.""" """share-by-map with conversion=bday complex tests."""
self.configure({"auth": {"type": "htpasswd", self.configure({"auth": {"type": "htpasswd",
@@ -5161,7 +5794,7 @@ permissions: RrWw""")
_, answer = self.get(path_mapped, login="owner:ownerpw") _, answer = self.get(path_mapped, login="owner:ownerpw")
assert "contact1" in answer assert "contact1" in answer
assert "contact2" in answer assert "contact2" in answer
assert "NICKNAME-C3" in answer assert "Family3Test" in answer
# create map # create map
logging.info("\n*** create bday owner to itself -> ok") logging.info("\n*** create bday owner to itself -> ok")
@@ -5277,6 +5910,7 @@ permissions: RrWw""")
"htpasswd_encryption": "plain"}, "htpasswd_encryption": "plain"},
"sharing": { "sharing": {
"type": "csv", "type": "csv",
"conversion_bday_summary_template": "{fn} (BDAY)",
"permit_create_token": True, "permit_create_token": True,
"permit_properties_overlay": "True", "permit_properties_overlay": "True",
"enforce_properties_overlay": "True", "enforce_properties_overlay": "True",
@@ -5328,7 +5962,7 @@ permissions: RrWw""")
_, answer = self.get(path_mapped, login="owner:ownerpw") _, answer = self.get(path_mapped, login="owner:ownerpw")
assert "contact1" in answer assert "contact1" in answer
assert "contact2" in answer assert "contact2" in answer
assert "NICKNAME-C3" in answer assert "Family3Test" in answer
# create map # create map
logging.info("\n*** create token with bday conversion (default permissions) -> ok") logging.info("\n*** create token with bday conversion (default permissions) -> ok")
@@ -5349,8 +5983,8 @@ permissions: RrWw""")
logging.info("\n*** GET bday with token") logging.info("\n*** GET bday with token")
_, answer = self.get(path_shared) _, answer = self.get(path_shared)
assert "VCARD" not in answer assert "VCARD" not in answer
assert "Test-FN-C3 (BDAY)" in answer assert "Test-FN-C3 (BDAY)" in answer # contact2
assert "Test-FN (BDAY)" in answer assert "Test-FN (BDAY)" in answer # contact1
# verify content as owner # verify content as owner
logging.info("\n*** GET collection owner -> ok") logging.info("\n*** GET collection owner -> ok")