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
## 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
* 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
_(>= 3.7.0)_
Default permissions for create token-based sharing
Default: `r`
@@ -2295,12 +2297,77 @@ Supported: `rwEePp`
##### default_permissions_create_map
_(>= 3.7.0)_
Default permissions for map-based sharing
Default: `r`
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
Radicale has been tested with:

View File

@@ -43,7 +43,7 @@ Types of supported sharing configuration:
* `TimestampCreated`: unixtime of creation
* `TimestampUpdated`: unixtime of last update
* `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
@@ -246,6 +246,7 @@ Can be selected by `HTTP_ACCEPT` - default is equal to provided `CONTENT_TYPE`
* `Enabled`: owner/user selected by authentication
* `Hidden`: owner/user selected by authentication
* `Properties`: properties to overlay
* `Actions`: specific configuration
### API Hooks
@@ -315,7 +316,8 @@ Create a share by mapping a collection of an `Owner` to a token.
| Permissions | str | optional (default:rp) |
| Enabled | bool | optional (owner/default:False) |
| Hidden | bool | optional (owner/default:True) |
| Properties | str | optional |
| Properties | str(dict) | optional |
| Actions | str(dict) | optional |
* 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) |
| Enabled | bool | optional (owner/default:False) |
| Hidden | bool | optional (owner/default:True) |
| Properties | optional |
| Properties | str(dict) | optional |
| Actions | str(dict) | optional |
* 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 |
| Enabled | 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
@@ -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.
### Examples
Preconditions:
@@ -687,6 +690,8 @@ Preconditions:
#### Examples using API
##### Mapping
* Create as *map*
```bash
@@ -712,6 +717,8 @@ END:VCALENDAR
Via WebUI an additional (virtual) calendar collection appears
##### Mapping as token
* Create as *token*
```bash
@@ -722,7 +729,7 @@ Status='success'
PathOrToken='/.token/v1/lqqwqhZYTGi9uSPsixien_8G5jiSK0FfhNFRGG_t8UA0/'
```
* Fetch *map*
* Fetch *token*
```bash
## Fetch VCALENDAR auto-created from VADDRESSBOOK

17
config
View File

@@ -356,6 +356,23 @@
# Supported: rwEePp
#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]

View File

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

View File

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

View File

@@ -267,7 +267,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
# autoconvert
retrieved_items_vcf_to_ics = []
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:
continue
else:

View File

@@ -593,7 +593,28 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
("default_permissions_create_map", {
"value": "r",
"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([
("type", {
"value": "none",

View File

@@ -38,7 +38,7 @@ from typing import (Any, Callable, List, MutableMapping, Optional, Sequence,
import vobject
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.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") + ")"
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]:
"""Wrapper for vobject.readComponents"""
@@ -362,6 +364,65 @@ def verify(file: str, encoding: str):
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 for address book and calendar entries."""
@@ -504,7 +565,8 @@ class Item:
self.component_name
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 (vobject): %r", self.vobject_item)
if self.vobject_item.name != "VCARD":
@@ -529,23 +591,54 @@ class Item:
else:
pass
placeholder_mapping: dict = {}
bdayS = match[1] + match[2] + match[3]
bdaySdesc = match[1] + "-" + match[2] + "-" + match[3]
bdayY = int(match[1])
bdayM = int(match[2])
bdayD = int(match[3])
placeholder_mapping['{year}'] = match[1]
placeholder_mapping['{month}'] = match[2]
placeholder_mapping['{day}'] = match[3]
# create ICS
if hasattr(self.vobject_item, "fn"):
name = self.vobject_item.fn.value
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"):
name = self.vobject_item.nickname.value
else:
logger.trace("item/convert_vcf_to_ics: has bday but neither FN or N or NICKNAME (skip): %r", self.href)
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
item_ics = vobject.newFromBehavior('vcalendar')
@@ -555,40 +648,134 @@ class Item:
else:
item_ics.add('prodid').value = PRODID_CONVERTED
# create EVENT
item_ics.add('vevent')
# prepare SUMMARY
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
dtstart = datetime.date(bdayY, bdayM, bdayD)
item_ics.vevent.add('dtstart').value = dtstart
# prepare DESCRIPTION
if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_description_template' in ShareActions['config']:
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
dtend = dtstart + datetime.timedelta(days=1)
item_ics.vevent.add('dtend').value = dtend
# create CATEGORIES
if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_categories' in ShareActions['config']:
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"):
pattern = re.compile('^(.*)-[0-9a-fA-F]{12}(.*)$')
match = pattern.match(self.vobject_item.uid.value)
if match:
# 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:
item_ics.vevent.add('uid').value = self.vobject_item.uid.value + UID_SUFFIX
uid = self.vobject_item.uid.value + UID_SUFFIX
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
item_ics.vevent.add('summary').value = name + " (BDAY)"
age = 0
while age <= age_max:
# create EVENT
vevent = item_ics.add('vevent')
# set RRULE
item_ics.vevent.add('rrule').value = "FREQ=YEARLY"
# set DTSTART
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
item_ics.vevent.add('transp').value = "TRANSPARENT"
# calculate and set DTEND
dtend = dtstart + datetime.timedelta(days=1)
vevent.add('dtend').value = dtend
# add description
item_ics.vevent.add('description').value = "BDAY=" + bdaySdesc
# set UID
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
if href is not None:

View File

@@ -24,11 +24,11 @@ import uuid
from csv import DictWriter
from datetime import datetime
from http import client
from typing import Sequence, Union
from typing import Any, Sequence, Union
from urllib.parse import parse_qs
from radicale import (config, httputils, pathutils, rights, storage, types,
utils)
from radicale import (config, httputils, item, pathutils, rights, storage,
types, utils)
from radicale.log import logger
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")
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")
@@ -166,6 +237,11 @@ class BaseSharing:
self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map")
self.permit_properties_overlay = configuration.get("sharing", "permit_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_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.permit_properties_overlay: %s", self.permit_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
self.sharing_db_type = configuration.get("sharing", "type")
@@ -403,6 +484,73 @@ class BaseSharing:
else:
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
# adjust a share
@@ -789,7 +937,31 @@ class BaseSharing:
return httputils.bad_request("Conversion not supported: %r" % Conversion)
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:
Enabled = request_data['Enabled']
@@ -1112,7 +1284,8 @@ class BaseSharing:
OwnerOrUser=user,
User=User,
Timestamp=Timestamp,
Properties=Properties)
Properties=Properties,
Actions=Actions)
else:
result = self.database_update_sharing(
ShareType=ShareType,
@@ -1124,7 +1297,8 @@ class BaseSharing:
OwnerOrUser=user,
User=User,
Timestamp=Timestamp,
Properties=Properties)
Properties=Properties,
Actions=Actions)
elif user == share['User']:
# 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 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 ': -> ":(whitespace)
field = field.replace("', '", '", "') # replace for JSON delimiter ', ' -> ", "
logger.trace("json prep quote replacer match (after) : %s", field)
try:

View File

@@ -223,7 +223,7 @@ class BaseCollection:
"""Get the HTTP-datetime of when the collection was modified."""
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."""
if self.tag == "VCALENDAR":
in_vcalendar = False
@@ -288,7 +288,7 @@ class BaseCollection:
items = []
for item in self.get_all():
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:
continue
else:

View File

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

View File

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

View File

@@ -301,7 +301,7 @@ class TestBaseAuthRequests(BaseTest):
delay = .3
delay_min = delay * 0.9 # 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
time_begin = datetime.datetime.now()

View File

@@ -4830,6 +4830,7 @@ permissions: RrWw""")
assert "DTEND;VALUE=DATE:19700102" in answer
assert "TRANSP:TRANSPARENT" in answer
assert "DESCRIPTION:BDAY=1970-01-01" in answer
assert "CATEGORIES:Birthday" in answer
# content type must be adjusted
assert 'Content-Type' in headers
assert 'text/calendar' in headers['Content-Type']
@@ -4879,6 +4880,9 @@ permissions: RrWw""")
assert "DTEND;VALUE=DATE:19700102" in answer
assert "TRANSP:TRANSPARENT" 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
assert 'Content-Type' in headers
assert 'text/calendar' in headers['Content-Type']
@@ -4931,6 +4935,635 @@ permissions: RrWw""")
status, prop = response["D:getcontenttype"]
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:
"""share-by-map with conversion=bday complex tests."""
self.configure({"auth": {"type": "htpasswd",
@@ -5161,7 +5794,7 @@ permissions: RrWw""")
_, answer = self.get(path_mapped, login="owner:ownerpw")
assert "contact1" in answer
assert "contact2" in answer
assert "NICKNAME-C3" in answer
assert "Family3Test" in answer
# create map
logging.info("\n*** create bday owner to itself -> ok")
@@ -5277,6 +5910,7 @@ permissions: RrWw""")
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"conversion_bday_summary_template": "{fn} (BDAY)",
"permit_create_token": True,
"permit_properties_overlay": "True",
"enforce_properties_overlay": "True",
@@ -5328,7 +5962,7 @@ permissions: RrWw""")
_, answer = self.get(path_mapped, login="owner:ownerpw")
assert "contact1" in answer
assert "contact2" in answer
assert "NICKNAME-C3" in answer
assert "Family3Test" in answer
# create map
logging.info("\n*** create token with bday conversion (default permissions) -> ok")
@@ -5349,8 +5983,8 @@ permissions: RrWw""")
logging.info("\n*** GET bday with token")
_, answer = self.get(path_shared)
assert "VCARD" not in answer
assert "Test-FN-C3 (BDAY)" in answer
assert "Test-FN (BDAY)" in answer
assert "Test-FN-C3 (BDAY)" in answer # contact2
assert "Test-FN (BDAY)" in answer # contact1
# verify content as owner
logging.info("\n*** GET collection owner -> ok")