diff --git a/.gitignore b/.gitignore index 09ebf86ce..6bfe34914 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ node_modules /static/dist /frontend/webpack-stats.json /frontend/src/translations.json +/frontend/.__mf__temp/ # TOX stuff test_project/modoboa.db diff --git a/dev-requirements.txt b/dev-requirements.txt index 34422e9a9..66e8b7f34 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --extra=dev --output-file=dev-requirements.txt pyproject.toml @@ -13,7 +13,9 @@ asgiref==3.8.1 # modoboa (pyproject.toml) astroid==3.3.8 # via pylint -attrs==25.1.0 +async-timeout==5.0.1 + # via redis +attrs==24.3.0 # via # jsonschema # referencing @@ -21,7 +23,9 @@ bcrypt==4.2.1 # via modoboa (pyproject.toml) black==25.1.0 # via modoboa (pyproject.toml) -certifi==2025.1.31 +caldav==1.3.9 + # via modoboa (pyproject.toml) +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography @@ -37,6 +41,7 @@ click==8.1.8 # via # black # rq + # x-wr-timezone cryptography==44.0.1 # via # fido2 @@ -69,6 +74,7 @@ django==4.2.18 # django-rq # django-xforwardedfor-middleware # djangorestframework + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) django-bower==5.2.0 @@ -103,10 +109,13 @@ django-xforwardedfor-middleware==2.0 # via modoboa (pyproject.toml) djangorestframework==3.15.2 # via + # drf-nested-routers # drf-spectacular # modoboa (pyproject.toml) dnspython==2.7.0 # via modoboa (pyproject.toml) +drf-nested-routers==0.94.1 + # via modoboa (pyproject.toml) drf-spectacular==0.28.0 # via modoboa (pyproject.toml) feedparser==6.0.11 @@ -121,7 +130,12 @@ gevent==24.11.1 # via modoboa (pyproject.toml) greenlet==3.1.1 # via gevent -identify==2.6.7 +icalendar==6.1.1 + # via + # caldav + # recurring-ical-events + # x-wr-timezone +identify==2.6.6 # via pre-commit idna==3.10 # via @@ -137,8 +151,10 @@ jsonschema-specifications==2024.10.1 # via jsonschema jwcrypto==1.5.6 # via django-oauth-toolkit -lxml==5.3.1 - # via lxml-html-clean +lxml==5.3.0 + # via + # caldav + # lxml-html-clean lxml-html-clean==0.4.1 # via modoboa (pyproject.toml) markupsafe==3.0.2 @@ -185,17 +201,26 @@ pylint==3.3.4 pyopenssl==25.0.0 # via modoboa (pyproject.toml) python-dateutil==2.9.0.post0 - # via modoboa (pyproject.toml) + # via + # icalendar + # modoboa (pyproject.toml) + # recurring-ical-events + # vobject python-magic==0.4.27 # via modoboa (pyproject.toml) -pytz==2025.1 - # via modoboa (pyproject.toml) +pytz==2024.2 + # via + # caldav + # modoboa (pyproject.toml) + # vobject pyyaml==6.0.2 # via # drf-spectacular # pre-commit qrcode==8.0 # via modoboa (pyproject.toml) +recurring-ical-events==3.4.1 + # via caldav redis==5.2.1 # via # django-rq @@ -209,6 +234,7 @@ reportlab==4.3.1 # via modoboa (pyproject.toml) requests==2.32.3 # via + # caldav # django-oauth-toolkit # modoboa (pyproject.toml) # ovh @@ -237,6 +263,7 @@ six==1.17.0 # via # django-bower # python-dateutil + # vobject sqlparse==0.5.3 # via # django @@ -249,7 +276,16 @@ typing-extensions==4.12.2 # via # dj-database-url # jwcrypto + # pyopenssl + # referencing # sievelib +tzdata==2025.1 + # via + # icalendar + # recurring-ical-events + # x-wr-timezone +tzlocal==5.2 + # via caldav uritemplate==4.1.1 # via # drf-spectacular @@ -258,8 +294,14 @@ urllib3==2.3.0 # via requests virtualenv==20.29.2 # via pre-commit +vobject==0.9.9 + # via + # caldav + # modoboa (pyproject.toml) werkzeug==3.1.3 # via modoboa (pyproject.toml) +x-wr-timezone==2.0.0 + # via recurring-ical-events zope-event==5.0 # via gevent zope-interface==7.2 diff --git a/docker-compose.yml b/docker-compose.yml index d26b288c5..b37488bee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,10 +9,11 @@ services: context: . dockerfile: docker/Dockerfile.dev environment: - PYTHONPATH: ".." DB: "SQLITE" DEBUG: "True" REDIS_HOST: redis + PYTHONPATH: ".." + PYTHONUNBUFFERED: 1 command: > sh -c "cd test_project; python3 manage.py rqworker dkim modoboa" volumes: @@ -29,16 +30,19 @@ services: env_file: - test_project/oidc_key environment: - PYTHONPATH: ".." DB: "SQLITE" DEBUG: "True" REDIS_HOST: redis + PYTHONPATH: ".." + PYTHONUNBUFFERED: 1 command: > sh -c "cd test_project python3 manage.py migrate python3 manage.py load_initial_data --dev django-admin compilemessages python3 manage.py runserver_plus 0.0.0.0:8000 --cert-file cert.crt" + depends_on: + - radicale volumes: - .:/code @@ -53,13 +57,16 @@ services: env_file: - test_project/oidc_key environment: - PYTHONPATH: ".." DB: "SQLITE" DEBUG: "True" REDIS_HOST: redis + PYTHONPATH: ".." + PYTHONUNBUFFERED: 1 command: > sh -c "cd test_project python3 manage.py runserver_plus 0.0.0.0:8001" + depends_on: + - radicale volumes: - .:/code @@ -89,3 +96,12 @@ services: - api volumes: - ./docker/dovecot_config:/etc/dovecot/conf.d + + radicale: + container_name: modoboa_radicale + restart: unless-stopped + build: + context: . + dockerfile: docker/Dockerfile.radicale.dev + volumes: + - ./docker/radicale_config:/etc/radicale diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 59773d207..f77e10fb0 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,6 +1,7 @@ FROM python:3.12-alpine3.20 AS base LABEL org.opencontainers.image.authors="Antoine Nguyen " + ENV VIRTUAL_ENV=/opt/venv ENV PATH="$VIRTUAL_ENV/bin:$PATH" @@ -16,7 +17,6 @@ COPY test-requirements.txt /tmp COPY dev-requirements.txt /tmp RUN python -m pip install -U pip RUN pip install -r requirements.txt -r test-requirements.txt -r dev-requirements.txt - FROM base AS run COPY docker/doveadm /usr/local/bin RUN chmod +x /usr/local/bin/doveadm diff --git a/docker/Dockerfile.front.dev b/docker/Dockerfile.front.dev index 6d3a0b90b..0dff8b433 100644 --- a/docker/Dockerfile.front.dev +++ b/docker/Dockerfile.front.dev @@ -7,4 +7,4 @@ RUN apk add --no-cache --update gettext \ WORKDIR $HOME -RUN yarn install +RUN yarn install --dev diff --git a/docker/Dockerfile.radicale.dev b/docker/Dockerfile.radicale.dev new file mode 100644 index 000000000..e93b4f9ed --- /dev/null +++ b/docker/Dockerfile.radicale.dev @@ -0,0 +1,11 @@ +FROM python:3.12-alpine3.20 + +LABEL org.opencontainers.image.authors="Antoine Nguyen " + +WORKDIR /app + +RUN pip install Radicale pytz radicale-modoboa-auth-oauth2 + +EXPOSE 5232 + +CMD python3 -m radicale diff --git a/docker/radicale_config/config b/docker/radicale_config/config new file mode 100644 index 000000000..c8934b2b5 --- /dev/null +++ b/docker/radicale_config/config @@ -0,0 +1,159 @@ +# -*- mode: conf -*- +# vim:ft=cfg + +# Config file for Radicale - A simple calendar server +# +# Place it into /etc/radicale/config (global) +# or ~/.config/radicale/config (user) +# +# The current values are the default ones + + +[server] + +# CalDAV server hostnames separated by a comma +# IPv4 syntax: address:port +# IPv6 syntax: [address]:port +# For example: 0.0.0.0:9999, [::]:9999 +hosts = 0.0.0.0:5232 + +# Daemon flag +#daemon = False + +# File storing the PID in daemon mode +#pid = + +# Max parallel connections +#max_connections = 20 + +# Max size of request body (bytes) +#max_content_length = 10000000 + +# Socket timeout (seconds) +#timeout = 10 + +# SSL flag, enable HTTPS protocol +#ssl = False + +# SSL certificate path +#certificate = /etc/ssl/radicale.cert.pem + +# SSL private key +#key = /etc/ssl/radicale.key.pem + +# CA certificate for validating clients. This can be used to secure +# TCP traffic between Radicale and a reverse proxy +#certificate_authority = + +# SSL Protocol used. See python's ssl module for available values +#protocol = PROTOCOL_TLSv1_2 + +# Available ciphers. See python's ssl module for available ciphers +#ciphers = + +# Reverse DNS to resolve client address in logs +#dns_lookup = True + +# Message displayed in the client when a password is needed +#realm = Radicale - Password Required + + +[encoding] + +# Encoding for responding requests +#request = utf-8 + +# Encoding for storing local collections +#stock = utf-8 + + +[auth] + +# Authentication method +# Value: none | htpasswd | remote_user | http_x_remote_user +type = radicale_modoboa_auth_oauth2 + +# Htpasswd filename +# htpasswd_filename = users + +# Htpasswd encryption method +# Value: plain | sha1 | ssha | crypt | bcrypt | md5 +# Only bcrypt can be considered secure. +# bcrypt and md5 require the passlib library to be installed. +# htpasswd_encryption = plain + +# Incorrect authentication delay (seconds) +#delay = 1 + +oauth2_introspection_endpoint = http://dovecot:Toto12345@api-unsecured:8001/api/o/introspect/ + + +[rights] + +# Rights backend +# Value: none | authenticated | owner_only | owner_write | from_file +type = from_file + +# File for rights management from_file +file = /etc/radicale/rights + + +[storage] + +# Storage backend +# Value: multifilesystem +# type = +# radicale_storage_by_index_fields = dtstart, dtend, uid, summary + +# Folder for storing local collections, created if not present +filesystem_folder = /collections + +# Lock the storage. Never start multiple instances of Radicale or edit the +# storage externally while Radicale is running if disabled. +#filesystem_locking = True + +# Sync all changes to disk during requests. (This can impair performance.) +# Disabling it increases the risk of data loss, when the system crashes or +# power fails! +#filesystem_fsync = True + +# Delete sync token that are older (seconds) +#max_sync_token_age = 2592000 + +# Close the lock file when no more clients are waiting. +# This option is not very useful in general, but on Windows files that are +# opened cannot be deleted. +#filesystem_close_lock_file = False + +# Command that is run after changes to storage +# Example: ([ -d .git ] || git init) && git add -A && (git diff --cached --quiet || git commit -m "Changes by "%%(user)s) +#hook = + + +[web] + +# Web interface backend +# Value: none | internal +type = none + + +[logging] + +# Logging configuration file +# If no config is given, simple information is printed on the standard output +# For more information about the syntax of the configuration file, see: +# http://docs.python.org/library/logging.config.html +#config = /etc/radicale/logging + +# Store all environment variables (including those set in the shell) +#full_environment = False + +# Don't include passwords in logs +#mask_passwords = True + +level = debug + +[headers] + +# Additional HTTP headers +#Access-Control-Allow-Origin = * diff --git a/docker/radicale_config/rights b/docker/radicale_config/rights new file mode 100644 index 000000000..2eb3979c7 --- /dev/null +++ b/docker/radicale_config/rights @@ -0,0 +1,135 @@ +# -*- mode: conf -*- +# vim:ft=cfg +# Allow all rights for the Administrator +#[root] +#user: Administrator +#collection: .* +#permissions: RW + +# Allow reading principal collection (same as username) +#[principal] +#user: .+ +#collection: {user} +#permissions: R + +# Allow reading and writing private collection (same as username) +#[private] +#user: .+ +#collection: {user}/private/ +#permissions: RW + +# Allow reading calendars and address books that are direct +# children of the principal collection for other users +#[calendarsReader] +#user: .+ +#collection: {user}/[^/]+ +#permissions: r + +# Rights management file for Radicale - A simple calendar server +# +# The default path for this file is /etc/radicale/rights +# The path can be specified in the rights section of the configuration file +# +# Section names are used for naming rules and must be unique. +# The first rule matching both user and collection patterns will be used. + + +# Example: owner_only plugin + +# Allow reading root collection for authenticated users +#[root] +#user: .+ +#collection: +#permissions: R + +# Allow reading and writing principal collection (same as username) +[principal] +user: .+ +collection: {user} +permissions: RW + +# Allow reading and writing calendars and address books that are direct +# children of the principal collection +[calendars] +user: .+ +collection: {user}/[^/]+ +permissions: rw + + +# Example: owner_write plugin +# Only listed additional rules for the owner_only plugin example. + +# Allow reading principal collections of all users +#[read-all-principals] +#user: .+ +#collection: [^/]+ +#permissions: R + +# Allow reading all calendars and address books that are direct children of any +# principal collection +#[read-all-calendars] +#user: .+ +#collection: [^/]+/[^/]+ +#permissions: r + + +# Example: authenticated plugin + +# Allow reading and writing root and principal collections of all users +#[root-and-principals] +#user: .+ +#collection: [^/]* +#permissions: RW + +# Allow reading and writing all calendars and address books that are direct +# children of any principal collection +#[calendars] +#user: .+ +#collection: [^/]+/[^/]+ +#permissions: rw + + +# Example: Allow user "admin" to read everything +#[admin-read-all] +#user: admin +#collection: .* +#permissions: Rr + + +# Example: Allow everybody (including unauthenticated users) to read +# the collection "public" + +# Allow reading collection "public" for authenticated users +#[public-principal] +#user: .+ +#collection: public +#permissions: R + +# Allow reading all calendars and address books that are direct children of +# the collection "public" for authenticated users +#[public-calendars] +#user: .+ +#collection: public/[^/]+ +#permissions: r + +# Allow access to public calendars and address books via HTTP GET for everyone +#[public-calendars-restricted] +#user: .* +#collection: public/[^/]+ +#permissions: i + +# Example: Grant users of the form user@domain.tld read access to the +# collection "domain.tld" + +# Allow reading the domain collection +#[read-domain-principal] +#user: .+@([^@]+) +#collection: {0} +#permissions: R + +# Allow reading all calendars and address books that are direct children of +# the domain collection +#[read-domain-calendars] +#user: .+@([^@]+) +#collection: {0}/[^/]+ +#permissions: r diff --git a/frontend/package.json b/frontend/package.json index af54d4be1..db58a9f23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,11 @@ "gettext:compile": "vue-gettext-compile" }, "dependencies": { + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@fullcalendar/vue3": "^6.1.15", "@github/webauthn-json": "^2.1.1", "@mdi/font": "^7.3.67", "apexcharts": "^3.50.0", diff --git a/frontend/src/api/account.js b/frontend/src/api/account.js index 1375c2436..e0782641f 100644 --- a/frontend/src/api/account.js +++ b/frontend/src/api/account.js @@ -114,4 +114,7 @@ export default { `${resource}/filtersets/${filterSetName}/filters/${filter}/` ) }, + getAvailableApplications() { + return repository.get(`${resource}/available_applications/`) + }, } diff --git a/frontend/src/api/calendars.js b/frontend/src/api/calendars.js new file mode 100644 index 000000000..9143758b5 --- /dev/null +++ b/frontend/src/api/calendars.js @@ -0,0 +1,58 @@ +import repository from './repository' + +export default { + getUserCalendars() { + return repository.get('/user-calendars/') + }, + getUserCalendar(pk) { + return repository.get(`/user-calendars/${pk}/`) + }, + createUserCalendar(data) { + return repository.post('/user-calendars/', data) + }, + updateUserCalendar(pk, data) { + return repository.put(`/user-calendars/${pk}/`, data) + }, + deleteUserCalendar(pk) { + return repository.delete(`/user-calendars/${pk}/`) + }, + + getAccessRules(calendarPk) { + return repository.get('/accessrules/') + }, + createAccessRule(data) { + return repository.post('/accessrules/', data) + }, + updateAccessRule(ruleId, data) { + return repository.put(`/accessrules/${ruleId}/`, data) + }, + deleteAccessRule(ruleId) { + return repository.delete(`/accessrules/${ruleId}/`) + }, + + getUserCalendarEvents(calendarPk, params) { + return repository.get(`/user-calendars/${calendarPk}/events/`, { params }) + }, + getUserEvent(calendarPk, eventId) { + return repository.get(`/user-calendars/${calendarPk}/events/${eventId}/`) + }, + createUserEvent(calendarPk, data) { + return repository.post(`/user-calendars/${calendarPk}/events/`, data) + }, + patchUserEvent(calendarPk, eventId, data) { + return repository.patch( + `/user-calendars/${calendarPk}/events/${eventId}/`, + data + ) + }, + importUserEvents(calendarPk, data) { + return repository.post( + `/user-calendars/${calendarPk}/events/import_from_file/`, + data + ) + }, + + getAttendees() { + return repository.get('/attendees/') + }, +} diff --git a/frontend/src/api/contacts.js b/frontend/src/api/contacts.js new file mode 100644 index 000000000..b28883e7d --- /dev/null +++ b/frontend/src/api/contacts.js @@ -0,0 +1,37 @@ +import repository from './repository' + +export default { + getDefaultAddressBook() { + return repository.get('/address-books/default/') + }, + synchronizeToAddressBook() { + return repository.get('/address-books/sync_to_cdav/') + }, + synchronizeFromAddressBook() { + return repository.get('/address-books/sync_from_cdav/') + }, + getContacts(params) { + return repository.get('/contacts/', { params }) + }, + createContact(data) { + return repository.post('/contacts/', data) + }, + updateContact(contactId, data) { + return repository.patch(`/contacts/${contactId}/`, data) + }, + deleteContact(contactId) { + return repository.delete(`/contacts/${contactId}/`) + }, + getCategories() { + return repository.get('/categories/') + }, + addCategory(data) { + return repository.post('/categories/', data) + }, + updateCategory(categoryId, data) { + return repository.patch(`/categories/${categoryId}/`, data) + }, + deleteCategory(categoryId) { + return repository.delete(`/categories/${categoryId}/`) + }, +} diff --git a/frontend/src/api/mailboxes.js b/frontend/src/api/mailboxes.js new file mode 100644 index 000000000..3db2e81b8 --- /dev/null +++ b/frontend/src/api/mailboxes.js @@ -0,0 +1,9 @@ +import repository from './repository' + +const resource = 'mailboxes' + +export default { + getAll() { + return repository.get(`${resource}/`) + }, +} diff --git a/frontend/src/api/parameters.js b/frontend/src/api/parameters.js index fb3140b17..805a1adf9 100644 --- a/frontend/src/api/parameters.js +++ b/frontend/src/api/parameters.js @@ -3,16 +3,29 @@ import repository from './repository' const resource = 'parameters' export default { - getApplications() { - return repository.get(`/${resource}/applications/`) + getGlobalApplications() { + return repository.get(`/${resource}/global/applications/`) }, - getApplication(app) { - return repository.get(`/${resource}/${app}/`) + getGlobalApplication(app) { + return repository.get(`/${resource}/global/${app}/`) }, - getApplicationStructure(app) { - return repository.get(`/${resource}/structure/?app=${app}`) + getGlobalApplicationStructure(app) { + return repository.get(`/${resource}/global/structure/?app=${app}`) }, - saveApplication(app, data) { - return repository.put(`/${resource}/${app}/`, data) + saveGlobalApplication(app, data) { + return repository.put(`/${resource}/global/${app}/`, data) + }, + + getUserApplications() { + return repository.get(`/${resource}/user/applications/`) + }, + getUserApplication(app) { + return repository.get(`/${resource}/user/${app}/`) + }, + getUserApplicationStructure(app) { + return repository.get(`/${resource}/user/structure/?app=${app}`) + }, + saveUserApplication(app, data) { + return repository.put(`/${resource}/user/${app}/`, data) }, } diff --git a/frontend/src/components/admin/alarms/AlarmList.vue b/frontend/src/components/admin/alarms/AlarmList.vue index 6c022dec0..cb2a56bd6 100644 --- a/frontend/src/components/admin/alarms/AlarmList.vue +++ b/frontend/src/components/admin/alarms/AlarmList.vue @@ -14,6 +14,17 @@ >