Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions app/api/v1/calendar/ApiCalendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

from typing import TYPE_CHECKING

from flask import g
from flask import g, request
from flask.views import MethodView
from flask.typing import ResponseReturnValue
from flask_smorest import Blueprint
from werkzeug.datastructures import FileStorage

from app.config.settings.DomainSettings import UserModuleSettings
from app.interface.calendar.InterfaceApiCalendarCalendar import InterfaceApiCalendarCalendar
from app.utils.api.ApiBaseResponse import create_api_base_response
from app.utils.api.is_async import AsyncQueryArgsSchema, async_endpoint
from app.utils.errors import ERROR_CALENDAR_IMPORT_NO_FILE
from app.utils.api.is_async import async_endpoint
from app.utils.errors import ERROR_CALENDAR_IMPORT_NO_FILE, ERROR_CALENDAR_SHARING_DISABLED
from app.utils.logger.logger import logger_api
from .schemas.calendar import (
CalendarCreateSchema,
Expand All @@ -23,6 +24,10 @@
CalendarImportResponseSchema,
CalendarImportUploadSchema,
CalendarSubscriptionResponseSchema,
CalendarSharePatchSchema,
CalendarSharePutSchema,
CalendarSharePostSchema,
CalendarShareResponseSchema,
)
from .schemas.event import (
AttendanceSchema,
Expand Down Expand Up @@ -58,7 +63,14 @@


@blp.before_request
def init_calendar_config() -> None: # pylint: disable=missing-function-docstring
def init_calendar_config() -> ResponseReturnValue | None: # pylint: disable=missing-function-docstring
if request.path.endswith("/share"):
user_domain_settings: dict = g.user_domain_settings
user_module_settings: dict = user_domain_settings.get(UserModuleSettings.subparent, {})
if "calendar" in user_module_settings.get("SOGO_D_FOLDER_DISABLE_SHARING", []):
logger_api.debug("Access denied for %s: calendar sharing is disabled", request.path)
return create_api_base_response(None, ERROR_CALENDAR_SHARING_DISABLED)

g.inter = InterfaceApiCalendarCalendar(
process_setting=g.process_settings,
user_domain_settings=g.user_domain_settings,
Expand Down Expand Up @@ -382,6 +394,49 @@ def get(self, query_args: dict) -> ResponseReturnValue:
return interface.get_reminders(query_args)


@blp.route("/calendars/<string:key>/share")
class ApiCalendarShare(MethodView):
"""API to manage calendar sharing and user permissions."""

@blp.response(200, CalendarShareResponseSchema, example=CalendarShareResponseSchema.example())
def get(self, key: str) -> ResponseReturnValue:
"""Get all user permissions for a calendar."""
logger_api.debug("GET /calendars/%s/share user=%s", key, g.user.uid)
interface: InterfaceApiCalendarCalendar = g.inter
return interface.get_calendar_share(key)

@blp.arguments(CalendarSharePatchSchema(many=True), example=CalendarSharePatchSchema.example()) # type: ignore [arg-type]
@blp.response(200, CalendarShareResponseSchema, example=CalendarShareResponseSchema.example())
def patch(self, body: list[dict], key: str) -> ResponseReturnValue:
"""Partially update user permissions for a calendar.

Only the users specified in the request body are modified.
Other existing permissions remain unchanged.
"""
logger_api.debug("PATCH /calendars/%s/share user=%s body=%s", key, g.user.uid, body)
interface: InterfaceApiCalendarCalendar = g.inter
return interface.patch_calendar_share(key, body)

@blp.arguments(CalendarSharePutSchema(many=True), example=CalendarSharePutSchema.example()) # type: ignore [arg-type]
@blp.response(200, CalendarShareResponseSchema, example=CalendarShareResponseSchema.example())
def put(self, body: list[dict], key: str) -> ResponseReturnValue:
"""Replace all user permissions for a calendar.

All existing permissions are replaced by the users specified in the request body.
"""
logger_api.debug("PUT /calendars/%s/share user=%s body=%s", key, g.user.uid, body)
interface: InterfaceApiCalendarCalendar = g.inter
return interface.put_calendar_share(key, body)

@blp.arguments(CalendarSharePostSchema(many=True), example=CalendarSharePostSchema.example()) # type: ignore [arg-type]
@blp.response(200, CalendarShareResponseSchema, example=CalendarShareResponseSchema.example())
def post(self, body: list[dict], key: str) -> ResponseReturnValue:
"""Grant full modify permissions to one or several users."""
logger_api.debug("POST /calendars/%s/share user=%s body=%s", key, g.user.uid, body)
interface: InterfaceApiCalendarCalendar = g.inter
return interface.post_calendar_share(key, body)


@blp.route("/external-calendars")
class ApiExternalCalendarList(MethodView):
"""API to list and create external ICS calendar subscriptions."""
Expand Down
182 changes: 181 additions & 1 deletion app/api/v1/calendar/schemas/calendar.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from marshmallow import Schema, fields, validate
from typing import Any
from marshmallow import Schema, fields, validate, validates_schema, ValidationError

from app.api.v1.calendar.schemas.components import CalendarPermissionsSchema
from app.api.v1.calendar.schemas.event import DateTimeEndUtcField, DateTimeUtcField
Expand Down Expand Up @@ -71,6 +72,8 @@ class CalendarSchema(Schema):
public_url = fields.String(allow_none=True, dump_only=True)
# `dump_only`` because permissions are only available when retrieving calendar but can't be set in that way
permissions = fields.Nested(CalendarPermissionsSchema, allow_none=True, dump_only=True)
owner = fields.String(allow_none=True, dump_only=True,
metadata={"description": "UID of the calendar's owner (creator, resolved via sogo6_acl for shared calendars).", "example": "jdoe"})
created_at = fields.DateTime(allow_none=True)
updated_at = fields.DateTime(allow_none=True)

Expand Down Expand Up @@ -158,3 +161,180 @@ class CalendarImportUploadSchema(Schema):
required=True,
metadata={"type": "string", "format": "binary", "description": "The .ics file to import."},
)


class CalendarShareRightsSchema(Schema):
"""Permission rights for different event visibility levels."""

public = fields.String(
required=True,
validate=validate.OneOf(["view-all", "view-date-time", "respond-to", "modify", "none"]),
metadata={"description": "Permission for public events: view-all | view-date-time | respond-to | modify | none", "example": "view-all"}
)
confidential = fields.String(
required=True,
validate=validate.OneOf(["view-all", "view-date-time", "respond-to", "modify", "none"]),
metadata={"description": "Permission for confidential events: view-all | view-date-time | respond-to | modify | none", "example": "view-date-time"}
)
private = fields.String(
required=True,
validate=validate.OneOf(["view-all", "view-date-time", "respond-to", "modify", "none"]),
metadata={"description": "Permission for private events: view-all | view-date-time | respond-to | modify | none", "example": "none"}
)
can_create_objects = fields.Boolean(required=True, metadata={"description": "Can create new events", "example": True})
can_erase_objects = fields.Boolean(required=True, metadata={"description": "Can delete events", "example": False})


class CalendarShareUserSchema(Schema):
"""User permission entry in calendar sharing.

``c_email`` and ``uid`` are required unless ``user_class`` is ``"anyone"``, in which case
they are ignored (the share applies to any authenticated user, not a specific one).
"""

c_email = fields.String(required=False, allow_none=True, metadata={"description": "User email address", "example": "jdoe@example.org"})
uid = fields.String(required=False, allow_none=True, metadata={"description": "User UID", "example": "jdoe"})
user_class = fields.String(
required=True,
validate=validate.OneOf(["user", "anyone"]),
)
rights = fields.Nested(CalendarShareRightsSchema, required=True, metadata={"description": "Permission rights for this user"})

@validates_schema
def validate_user_identity(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument
"""Require c_email and uid unless user_class is 'anyone'."""
if data.get("user_class") == "anyone":
return
errors: dict[str, list[str]] = {}
if not data.get("c_email"):
errors["c_email"] = ["Missing data for required field."]
if not data.get("uid"):
errors["uid"] = ["Missing data for required field."]
if errors:
raise ValidationError(errors)

class CalendarSharePatchSchema(CalendarShareUserSchema):
"""Request body item for PATCH /calendars/{key}/share - partial update of user permissions.

The endpoint expects a JSON list of these objects (use with ``many=True``).
Only the users specified in the request are modified. Other existing permissions remain unchanged.
"""

class Meta:
ordered = True

@staticmethod
def example() -> list[dict[str, Any]]:
"""Example data for Swagger documentation."""
return [
{
"c_email": "jdoe@example.org",
"uid": "jdoe",
"user_class": "user",
"rights": {
"public": "view-all",
"confidential": "view-date-time",
"private": "none",
"can_create_objects": True,
"can_erase_objects": False
}
}
]


class CalendarSharePutSchema(CalendarShareUserSchema):
"""Request body item for PUT /calendars/{key}/share - replace all user permissions.

The endpoint expects a JSON list of these objects (use with ``many=True``).
All existing permissions are replaced by the users specified in the request.
"""

class Meta:
ordered = True

@staticmethod
def example() -> list[dict[str, Any]]:
"""Example data for Swagger documentation."""
return [
{
"c_email": "jdoe@example.org",
"uid": "jdoe",
"user_class": "user",
"rights": {
"public": "view-all",
"confidential": "view-date-time",
"private": "none",
"can_create_objects": True,
"can_erase_objects": False
}
},
{
"c_email": "alice@example.org",
"uid": "alice",
"user_class": "user",
"rights": {
"public": "modify",
"confidential": "modify",
"private": "view-date-time",
"can_create_objects": True,
"can_erase_objects": True
}
}
]


class CalendarSharePostSchema(CalendarShareUserSchema):
"""Request body item for POST /calendars/{key}/share - grant full modify permissions to users.

The endpoint expects a JSON list of these objects (use with ``many=True``).
Grants 'modify' permission for all event types and object management rights to the specified users.
"""

class Meta:
ordered = True

@staticmethod
def example() -> list[dict[str, Any]]:
"""Example data for Swagger documentation."""
return [
{
"c_email": "jdoe@example.org",
"uid": "jdoe",
"user_class": "user",
"rights": {
"public": "modify",
"confidential": "modify",
"private": "modify",
"can_create_objects": True,
"can_erase_objects": True
}
}
]


class CalendarShareResponseSchema(ApiBaseResponse):
"""Response schema for calendar sharing endpoints. ``data`` is a plain list of users."""

data = fields.List(fields.Nested(CalendarShareUserSchema), allow_none=True)

@staticmethod
def example() -> dict[str, Any]:
"""Example full envelope for Swagger documentation."""
return {
"data": [
{
"c_email": "jdoe@example.org",
"uid": "jdoe",
"user_class": "user",
"rights": {
"public": "view-all",
"confidential": "view-date-time",
"private": "none",
"can_create_objects": True,
"can_erase_objects": False
}
}
],
"error_code": "S000000",
"error_msg": "No Error"
}
59 changes: 57 additions & 2 deletions app/api/v1/contact/ApiContact.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
from flask.typing import ResponseReturnValue
from flask_smorest import Blueprint

from app.config.settings.DomainSettings import UserModuleSettings
from app.interface.contact.InterfaceApiContactContact import InterfaceApiContactContact
from app.module.contact.ContactConst import IMPORT_MAX_BYTES
from app.module.contact.source.ContactSourceDb import LIST_SORTABLE_COLUMNS, SORTABLE_COLUMNS
from app.utils.api.ApiBaseResponse import create_api_base_response
from app.utils.api.paginate_sort_filter import collection_paginate, CustomPaginateResponse
from app.utils.errors import ERROR_CONTACT_IMPORT_NO_FILE, ERROR_CONTACT_IMPORT_TOO_LARGE
from app.utils.errors import ERROR_CONTACT_IMPORT_NO_FILE, ERROR_CONTACT_IMPORT_TOO_LARGE, ERROR_CONTACT_SHARING_DISABLED
from app.utils.logger.logger import logger_api
from .schemas.addressbook import (
AddressBookCreateSchema,
Expand All @@ -22,6 +23,10 @@
ContactImportQueryArgsSchema,
ContactImportUploadSchema,
ContactJobResponseSchema,
ContactSharePatchSchema,
ContactSharePutSchema,
ContactSharePostSchema,
ContactShareResponseSchema,
)
from .schemas.contact import (
ContactCreateSchema,
Expand Down Expand Up @@ -67,7 +72,14 @@


@blp.before_request
def init_contact_config() -> None: # pylint: disable=missing-function-docstring
def init_contact_config() -> ResponseReturnValue | None: # pylint: disable=missing-function-docstring
if request.path.endswith("/share"):
user_domain_settings: dict = g.user_domain_settings
user_module_settings: dict = user_domain_settings.get(UserModuleSettings.subparent, {})
if "contact" in user_module_settings.get("SOGO_D_FOLDER_DISABLE_SHARING", []):
logger_api.debug("Access denied for %s: contact sharing is disabled", request.path)
return create_api_base_response(None, ERROR_CONTACT_SHARING_DISABLED)

g.inter = InterfaceApiContactContact(
process_setting=g.process_settings,
user_domain_settings=g.user_domain_settings,
Expand Down Expand Up @@ -122,6 +134,49 @@ def delete(self, key: str) -> ResponseReturnValue:
return interface.delete_addressbook(key)


@blp.route("/addressbooks/<string:key>/share")
class ApiAddressBookShare(MethodView):
"""API to manage address book sharing and user permissions."""

@blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example())
def get(self, key: str) -> ResponseReturnValue:
"""Get all user permissions for an address book."""
logger_api.debug("GET /addressbooks/%s/share user=%s", key, g.user.uid)
interface: InterfaceApiContactContact = g.inter
return interface.get_addressbook_share(key)

@blp.arguments(ContactSharePatchSchema(many=True), example=ContactSharePatchSchema.example()) # type: ignore [arg-type]
@blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example())
def patch(self, body: list[dict], key: str) -> ResponseReturnValue:
"""Partially update user permissions for an address book.

Only the users specified in the request body are modified.
Other existing permissions remain unchanged.
"""
logger_api.debug("PATCH /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body)
interface: InterfaceApiContactContact = g.inter
return interface.patch_addressbook_share(key, body)

@blp.arguments(ContactSharePutSchema(many=True), example=ContactSharePutSchema.example()) # type: ignore [arg-type]
@blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example())
def put(self, body: list[dict], key: str) -> ResponseReturnValue:
"""Replace all user permissions for an address book.

All existing permissions are replaced by the users specified in the request body.
"""
logger_api.debug("PUT /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body)
interface: InterfaceApiContactContact = g.inter
return interface.put_addressbook_share(key, body)

@blp.arguments(ContactSharePostSchema(many=True), example=ContactSharePostSchema.example()) # type: ignore [arg-type]
@blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example())
def post(self, body: list[dict], key: str) -> ResponseReturnValue:
"""Grant full permissions to one or several users."""
logger_api.debug("POST /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body)
interface: InterfaceApiContactContact = g.inter
return interface.post_addressbook_share(key, body)


@blp.route("/addressbooks/<string:key>/contacts")
class ApiAddressBookContactList(MethodView):
"""API to list (paginated) and create contacts within one address book."""
Expand Down
Loading
Loading