mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-18 05:51:26 +02:00
Merge pull request #3348 from modoboa/frontend_module_federation
Integrate user plugins (contacts, calendars)
This commit is contained in:
@@ -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
|
||||
|
||||
+51
-9
@@ -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
|
||||
|
||||
+19
-3
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
FROM python:3.12-alpine3.20 AS base
|
||||
|
||||
LABEL org.opencontainers.image.authors="Antoine Nguyen <tonio@ngyn.org>"
|
||||
|
||||
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
|
||||
|
||||
@@ -7,4 +7,4 @@ RUN apk add --no-cache --update gettext \
|
||||
|
||||
WORKDIR $HOME
|
||||
|
||||
RUN yarn install
|
||||
RUN yarn install --dev
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-alpine3.20
|
||||
|
||||
LABEL org.opencontainers.image.authors="Antoine Nguyen <tonio@ngyn.org>"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install Radicale pytz radicale-modoboa-auth-oauth2
|
||||
|
||||
EXPOSE 5232
|
||||
|
||||
CMD python3 -m radicale
|
||||
@@ -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 = *
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -114,4 +114,7 @@ export default {
|
||||
`${resource}/filtersets/${filterSetName}/filters/${filter}/`
|
||||
)
|
||||
},
|
||||
getAvailableApplications() {
|
||||
return repository.get(`${resource}/available_applications/`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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/')
|
||||
},
|
||||
}
|
||||
@@ -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}/`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import repository from './repository'
|
||||
|
||||
const resource = 'mailboxes'
|
||||
|
||||
export default {
|
||||
getAll() {
|
||||
return repository.get(`${resource}/`)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar color="white" flat>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
flat
|
||||
single-line
|
||||
class="flex-grow-0 w-33 mr-4"
|
||||
></v-text-field>
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" size="small">
|
||||
@@ -25,17 +36,6 @@
|
||||
<MenuItems :items="getActionMenuItems()" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
flat
|
||||
single-line
|
||||
></v-text-field>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
<template #[`item.status`]="{ item }">
|
||||
|
||||
@@ -110,7 +110,7 @@ const domain = ref(defaultDomain)
|
||||
|
||||
onMounted(() => {
|
||||
//TODO : Store this in Pinia
|
||||
ParametersApi.getApplication('admin').then((resp) => {
|
||||
ParametersApi.getGlobalApplication('admin').then((resp) => {
|
||||
const params = resp.data.params
|
||||
domain.value.quota = params.default_domain_quota
|
||||
domain.value.default_mailbox_quota = params.default_mailbox_quota
|
||||
|
||||
@@ -261,7 +261,7 @@ async function save() {
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
parametersApi.getApplication('limits').then((resp) => {
|
||||
parametersApi.getGlobalApplication('limits').then((resp) => {
|
||||
limitsConfig.value.data = resp.data
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar color="white" flat>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="$gettext('Search')"
|
||||
variant="outlined"
|
||||
single-line
|
||||
flat
|
||||
hide-details
|
||||
density="compact"
|
||||
class="flex-grow-0 w-33 mr-4"
|
||||
></v-text-field>
|
||||
<slot name="extraActions" />
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
@@ -31,16 +43,6 @@
|
||||
icon="mdi-reload"
|
||||
@click="reloadDomains"
|
||||
></v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="$gettext('Search')"
|
||||
variant="outlined"
|
||||
single-line
|
||||
flat
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
<template
|
||||
|
||||
@@ -316,7 +316,7 @@ onMounted(() => {
|
||||
.getAccount(route.params.id)
|
||||
.then((response) => (editedAccount.value = { ...response.data }))
|
||||
|
||||
parametersApi.getApplication('limits').then((response) => {
|
||||
parametersApi.getGlobalApplication('limits').then((response) => {
|
||||
limitsConfig.value = response.data
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar flat color="white">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="$gettext('Search')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
single-line
|
||||
flat
|
||||
density="compact"
|
||||
class="flex-grow-0 w-33 mr-4"
|
||||
></v-text-field>
|
||||
<slot name="extraActions" />
|
||||
<v-menu location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
@@ -33,15 +45,6 @@
|
||||
@click="fetchIdentities"
|
||||
></v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="$gettext('Search')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
single-line
|
||||
flat
|
||||
></v-text-field>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
<template #[`item.identity`]="{ item }">
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar flat color="white">
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="$gettext('Search')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="flex-grow-0 w-33"
|
||||
/>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar flat color="white">
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="$gettext('Search')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="flex-grow-0 w-33"
|
||||
/>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Calendar sharing') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form ref="formRef" @submit.prevent="submit">
|
||||
<div class="d-flex">
|
||||
<v-select
|
||||
v-model="currentRule.mailbox"
|
||||
:label="$gettext('Full address')"
|
||||
:form-errors="formErrors['mailbox']"
|
||||
:items="mailboxes"
|
||||
item-title="full_address"
|
||||
return-object
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="currentRule.read"
|
||||
:label="$gettext('Read')"
|
||||
class="ml-4"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="currentRule.write"
|
||||
:label="$gettext('Write')"
|
||||
class="ml-4"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
icon="mdi-content-save-outline"
|
||||
type="submit"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</v-form>
|
||||
<br />
|
||||
<v-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $gettext('Account') }}</th>
|
||||
<th>{{ $gettext('Read') }}</th>
|
||||
<th>{{ $gettext('Write') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in accessRules" :key="rule.pk">
|
||||
<td>{{ rule.mailbox.full_address }}</td>
|
||||
<td>
|
||||
<v-icon :icon="rule.read ? 'mdi-check' : 'mdi-close'" />
|
||||
</td>
|
||||
<td>
|
||||
<v-icon :icon="rule.write ? 'mdi-check' : 'mdi-close'" />
|
||||
</td>
|
||||
<td>
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
@click="editRule(rule)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-trash-can"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
@click="deleteRule(rule)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api/calendars'
|
||||
import mailboxesApi from '@/api/mailboxes'
|
||||
|
||||
const props = defineProps({
|
||||
calendarPk: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const accessRules = ref([])
|
||||
const mailboxes = ref([])
|
||||
const currentRule = ref({})
|
||||
const formErrors = ref({})
|
||||
|
||||
api.getAccessRules(props.calendarPk).then((response) => {
|
||||
accessRules.value = response.data
|
||||
if (!accessRules.value.length) {
|
||||
accessRules.value = []
|
||||
}
|
||||
})
|
||||
mailboxesApi.getAll().then((response) => {
|
||||
mailboxes.value = response.data
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!currentRule.value.pk) {
|
||||
currentRule.value.calendar = props.calendarPk
|
||||
api.createAccessRule(currentRule.value).then((response) => {
|
||||
accessRules.value.push(response.data)
|
||||
resetForm()
|
||||
}, onError)
|
||||
} else {
|
||||
api.updateAccessRule(currentRule.value.pk, currentRule.value).then(() => {
|
||||
accessRules.value.filter((item, pos) => {
|
||||
if (item.pk === currentRule.value.pk) {
|
||||
accessRules.value[pos] = currentRule.value
|
||||
}
|
||||
})
|
||||
resetForm()
|
||||
}, onError)
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentRule.value = {}
|
||||
formErrors.value = {}
|
||||
}
|
||||
function onError(response) {
|
||||
formErrors.value = response.data
|
||||
}
|
||||
function editRule(rule) {
|
||||
currentRule.value = JSON.parse(JSON.stringify(rule))
|
||||
}
|
||||
function deleteRule(rule) {
|
||||
api.deleteAccessRule(rule.pk).then(() => {
|
||||
accessRules.value = accessRules.value.filter((localRule) => {
|
||||
return localRule.pk !== rule.pk
|
||||
})
|
||||
})
|
||||
}
|
||||
function close() {
|
||||
formErrors.value = {}
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<v-card v-if="calendar">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Calendar information') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'To access this calendar from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<v-alert type="info" class="my-4" variant="tonal">
|
||||
{{ calendar.full_url }}
|
||||
</v-alert>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'The credentials are the same than the ones you use to access Modoboa.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'You can also share a read-only version of this calendar using the following URL: '
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<v-alert type="info" class="my-4" variant="tonal">
|
||||
{{ calendar.share_url }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('close')">{{ $gettext('Close') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
calendar: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="submit">
|
||||
<v-card-title>
|
||||
<span v-if="calendar.pk" class="headline">{{
|
||||
$gettext('Edit calendar')
|
||||
}}</span>
|
||||
<span v-else class="modal-title">{{ $gettext('New calendar') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="calendar.name"
|
||||
:label="$gettext('Name')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.name"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-color-picker
|
||||
v-model="calendar.color"
|
||||
:label="$gettext('Color')"
|
||||
class="ma-2"
|
||||
swatches-max-height="200px"
|
||||
show-swatches
|
||||
hide-inputs
|
||||
:rules="[rules.required]"
|
||||
></v-color-picker>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
|
||||
<v-btn color="primary" type="submit">
|
||||
{{ submitLabel }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import rules from '@/plugins/rules'
|
||||
import calendarsApi from '@/api/calendars'
|
||||
|
||||
const emit = defineEmits(['close', 'colorChanged'])
|
||||
const props = defineProps({
|
||||
initialCalendar: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return { color: '' }
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
onMounted(() => {
|
||||
calendar.value = { ...props.initialCalendar }
|
||||
shared.value = props.initialCalendar.domain !== undefined
|
||||
})
|
||||
|
||||
const calendar = ref({})
|
||||
const shared = ref(false)
|
||||
const formErrors = ref({})
|
||||
const formRef = ref()
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (calendar.value.pk) {
|
||||
return $gettext('Update')
|
||||
}
|
||||
return $gettext('Create')
|
||||
})
|
||||
|
||||
function close() {
|
||||
calendar.value = {}
|
||||
shared.value = false
|
||||
formErrors.value = {}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
let msg
|
||||
if (calendar.value.pk) {
|
||||
await calendarsApi.updateUserCalendar(calendar.value.pk, calendar.value)
|
||||
msg = $gettext('Calendar updated')
|
||||
} else {
|
||||
await calendarsApi.createUserCalendar(calendar.value)
|
||||
msg = $gettext('Calendar created')
|
||||
}
|
||||
if (props.initialCalendar.color !== calendar.value.color) {
|
||||
emit('colorChanged', calendar.value)
|
||||
}
|
||||
close()
|
||||
busStore.displayNotification({
|
||||
msg,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="saveEvent">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ title }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4">
|
||||
<v-row class="mb-2">
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="form.title"
|
||||
:label="$gettext('Title')"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.title"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div class="d-flex mb-3">
|
||||
<v-icon icon="mdi-clock-outline" class="mr-4" />
|
||||
<v-text-field
|
||||
v-model="form.start"
|
||||
:label="$gettext('From')"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.start"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
type="datetime-local"
|
||||
class="mr-4"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.end"
|
||||
:label="$gettext('To')"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.end"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
type="datetime-local"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex mb-3">
|
||||
<v-icon icon="mdi-calendar" class="mr-4" />
|
||||
<v-select
|
||||
v-model="form.calendar"
|
||||
:items="userCalendars"
|
||||
item-title="name"
|
||||
item-value="pk"
|
||||
:label="$gettext('Calendar')"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors.calendar"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="event">
|
||||
<div class="d-flex mb-3">
|
||||
<v-icon icon="mdi-account-multiple-outline" class="mr-4" />
|
||||
<v-select
|
||||
v-model="form.attendees"
|
||||
:label="$gettext('Attendees')"
|
||||
:items="attendees"
|
||||
item-title="display_name"
|
||||
item-value="email"
|
||||
return-object
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
multiple
|
||||
chips
|
||||
/>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-model="form.description"
|
||||
:label="$gettext('Description')"
|
||||
auto-grow
|
||||
rows="2"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
/>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="close">{{ $gettext('Close') }}</v-btn>
|
||||
<v-btn color="primary" type="submit">
|
||||
{{ $gettext('Save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { DateTime } from 'luxon'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import api from '@/api/calendars'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
event: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const attendees = ref([])
|
||||
const userCalendars = ref([])
|
||||
const form = ref({})
|
||||
const formErrors = ref({})
|
||||
const formRef = ref()
|
||||
|
||||
const title = computed(() => {
|
||||
return props.event ? $gettext('Edit event') : $gettext('New event')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.info,
|
||||
(value) => {
|
||||
if (value) {
|
||||
form.value = {
|
||||
start: DateTime.fromISO(value.startStr).toFormat('yyyy-MM-dd HH:mm'),
|
||||
end: DateTime.fromISO(value.endStr).toFormat('yyyy-MM-dd HH:mm'),
|
||||
allDay: value.allDay,
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.event,
|
||||
(value) => {
|
||||
if (value) {
|
||||
form.value = { ...value }
|
||||
form.value.calendar = form.value.calendar.pk
|
||||
form.value.start = DateTime.fromISO(value.start).toFormat(
|
||||
'yyyy-MM-dd HH:mm'
|
||||
)
|
||||
form.value.end = DateTime.fromISO(value.end).toFormat('yyyy-MM-dd HH:mm')
|
||||
console.log(form.value)
|
||||
api.getAttendees().then((resp) => {
|
||||
attendees.value = resp.data
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function close() {
|
||||
formErrors.value = {}
|
||||
emit('close')
|
||||
}
|
||||
async function saveEvent() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
var data = JSON.parse(JSON.stringify(form.value))
|
||||
if (!props.event) {
|
||||
api.createUserEvent(form.value.calendar, data).then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Event added') })
|
||||
close()
|
||||
})
|
||||
} else {
|
||||
api.patchUserEvent(form.value.calendar, props.event.id, data).then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Event updated') })
|
||||
close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
api.getUserCalendars().then((resp) => {
|
||||
userCalendars.value = resp.data
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="sendFile">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Import events') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="info" variant="tonal" class="my-4">
|
||||
{{
|
||||
$gettext(
|
||||
'Select an ICS file to import and click on the Send button'
|
||||
)
|
||||
}}
|
||||
</v-alert>
|
||||
<v-file-input
|
||||
v-model="file"
|
||||
:label="$gettext('Select an ICS file')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
:error-messages="formErrors['ics_file']"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn :disabled="runningUpload" @click="close">{{
|
||||
$gettext('Close')
|
||||
}}</v-btn>
|
||||
<v-btn type="submit" color="primary" :disabled="runningUpload">
|
||||
{{ $gettext('Send') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useBusStore } from '@/stores'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import api from '@/api/calendars'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const props = defineProps({
|
||||
calendar: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close', 'eventsImported'])
|
||||
|
||||
const { $ngettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const file = ref(null)
|
||||
const formErrors = ref({})
|
||||
const formRef = ref()
|
||||
const runningUpload = ref(false)
|
||||
|
||||
function close() {
|
||||
formErrors.value = {}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onSendError(response) {
|
||||
formErrors.value = response.data
|
||||
runningUpload.value = false
|
||||
}
|
||||
|
||||
async function sendFile() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
let data = new FormData()
|
||||
data.append('ics_file', file.value)
|
||||
runningUpload.value = true
|
||||
try {
|
||||
const response = await api.importUserEvents(props.calendar, data)
|
||||
close()
|
||||
var msg = $ngettext(
|
||||
'%{ n } event imported',
|
||||
'%{ n } events imported',
|
||||
response.data.counter
|
||||
)
|
||||
busStore.displayNotification({ msg })
|
||||
emit('eventsImported', props.calendar)
|
||||
} catch (err) {
|
||||
onSendError(err.response)
|
||||
} finally {
|
||||
runningUpload.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Address book information') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'To access this address book from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<v-alert type="info" class="my-4" variant="tonal">
|
||||
{{ addressBookUrl }}
|
||||
</v-alert>
|
||||
<p>
|
||||
{{
|
||||
$gettext(
|
||||
'The credentials are the same than the ones you use to access Modoboa.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="$emit('close')">{{ $gettext('Close') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
addressBookUrl: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="submit">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ title }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
:label="$gettext('Name')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn v-if="category" color="red" @click="deleteCategory">
|
||||
{{ $gettext('Delete') }}
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn :loading="working" @click="close">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" type="submit" :loading="working">
|
||||
{{ submitLabel }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import contactsApi from '@/api/contacts'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const props = defineProps({
|
||||
category: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close', 'updated'])
|
||||
|
||||
const router = useRouter()
|
||||
const { $gettext } = useGettext()
|
||||
const { displayNotification } = useBusStore()
|
||||
|
||||
const form = ref({})
|
||||
const formRef = ref()
|
||||
const working = ref(false)
|
||||
|
||||
const title = computed(() => {
|
||||
return props.category ? $gettext('Edit category') : $gettext('Add category')
|
||||
})
|
||||
const submitLabel = computed(() => {
|
||||
return props.category ? $gettext('Update') : $gettext('Add')
|
||||
})
|
||||
|
||||
function close() {
|
||||
form.value = {}
|
||||
emit('close')
|
||||
}
|
||||
async function submit() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
working.value = true
|
||||
try {
|
||||
if (props.category) {
|
||||
await contactsApi.updateCategory(props.category.pk, form.value)
|
||||
} else {
|
||||
await contactsApi.addCategory(form.value)
|
||||
}
|
||||
emit('updated')
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCategory() {
|
||||
contactsApi.deleteCategory(props.category.pk).then(() => {
|
||||
close()
|
||||
emit('updated')
|
||||
displayNotification({ msg: $gettext('Category deleted') })
|
||||
router.push({ name: 'ContactList' })
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.category,
|
||||
(value) => {
|
||||
if (value) {
|
||||
form.value = JSON.parse(JSON.stringify(value))
|
||||
} else {
|
||||
form.value = {}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="submit">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ $gettext('Categories') }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-checkbox
|
||||
v-for="category in categories"
|
||||
:key="category.pk"
|
||||
v-model="selectedCategories"
|
||||
:label="category.name"
|
||||
hide-details
|
||||
:value="category.pk"
|
||||
color="primary"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn :loading="working" @click="close">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" type="submit" :loading="working">
|
||||
{{ $gettext('Update') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import contactsApi from '@/api/contacts'
|
||||
|
||||
const props = defineProps({
|
||||
contact: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const categories = ref([])
|
||||
const selectedCategories = ref([])
|
||||
const working = ref(false)
|
||||
|
||||
function close() {
|
||||
emit('close', 'updated')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const data = { categories: selectedCategories.value }
|
||||
working.value = true
|
||||
try {
|
||||
await contactsApi.updateContact(props.contact.pk, data)
|
||||
emit('updated')
|
||||
close()
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.contact,
|
||||
(value) => {
|
||||
if (value) {
|
||||
selectedCategories.value = value.categories
|
||||
} else {
|
||||
selectedCategories.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
contactsApi.getCategories().then((resp) => {
|
||||
categories.value = resp.data
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-form ref="formRef" @submit.prevent="submit">
|
||||
<v-card-title>
|
||||
<span class="headline">{{ title }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row no-gutters>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="form.first_name"
|
||||
:label="$gettext('First name')"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-account"
|
||||
density="compact"
|
||||
:error-messages="formErrors.first_name"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6" class="pl-4">
|
||||
<v-text-field
|
||||
v-model="form.last_name"
|
||||
:label="$gettext('Last name')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:error-messages="formErrors.last_name"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="form.display_name"
|
||||
:label="$gettext('Display name')"
|
||||
variant="outlined"
|
||||
prepend-icon="toto"
|
||||
density="compact"
|
||||
:error-messages="formErrors.display_name"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="form.company"
|
||||
:label="$gettext('Company')"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-office-building"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6" class="pl-4">
|
||||
<v-text-field
|
||||
v-model="form.position"
|
||||
:label="$gettext('Position')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<EmailField
|
||||
v-for="(email, index) in form.emails"
|
||||
:key="`email-${index}`"
|
||||
v-model="form.emails[index]"
|
||||
:index="index"
|
||||
@add="addEmailField"
|
||||
@delete="deleteEmailField"
|
||||
/>
|
||||
<PhoneNumberField
|
||||
v-for="(phone, index) in form.phone_numbers"
|
||||
:key="`phone-${index}`"
|
||||
v-model="form.phone_numbers[index]"
|
||||
:index="index"
|
||||
@add="addPhoneNumberField"
|
||||
@delete="deletePhoneNumberField"
|
||||
/>
|
||||
<template v-if="showExtraFields">
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="form.birth_date"
|
||||
:label="$gettext('Birth date')"
|
||||
type="date"
|
||||
prepend-icon="mdi-calendar"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
v-model="form.address"
|
||||
:label="$gettext('Address')"
|
||||
prepend-icon="mdi-location"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="2"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="form.zipcode"
|
||||
:label="$gettext('Zip code')"
|
||||
prepend-icon="toto"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6" class="pl-4">
|
||||
<v-text-field
|
||||
v-model="form.city"
|
||||
:label="$gettext('City')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="form.country"
|
||||
:label="$gettext('Country')"
|
||||
prepend-icon="toto"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6" class="pl-4">
|
||||
<v-text-field
|
||||
v-model="form.state"
|
||||
:label="$gettext('State/Province')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
v-model="form.note"
|
||||
:label="$gettext('Note')"
|
||||
prepend-icon="mdi-file"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="2"
|
||||
/>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn v-if="!showExtraFields" @click="showExtraFields = true">
|
||||
{{ $gettext('More') }}
|
||||
</v-btn>
|
||||
<v-btn v-else @click="showExtraFields = false">
|
||||
{{ $gettext('Less') }}
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn :loading="working" @click="emit('close')">
|
||||
{{ $gettext('Close') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" type="submit" :loading="working">
|
||||
{{ submitLabel }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import contactsApi from '@/api/contacts'
|
||||
import EmailField from './EmailField.vue'
|
||||
import PhoneNumberField from './PhoneNumberField.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'added', 'updated'])
|
||||
const props = defineProps({
|
||||
contact: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const form = ref(initialForm())
|
||||
const showExtraFields = ref(false)
|
||||
const formRef = ref()
|
||||
const formErrors = ref({})
|
||||
const working = ref(false)
|
||||
|
||||
const title = computed(() => {
|
||||
return props.contact ? $gettext('Edit contact') : $gettext('New contact')
|
||||
})
|
||||
const submitLabel = computed(() => {
|
||||
return props.contact ? $gettext('Update') : $gettext('Add')
|
||||
})
|
||||
|
||||
function initialForm() {
|
||||
return {
|
||||
emails: [{}],
|
||||
phone_numbers: [{}],
|
||||
}
|
||||
}
|
||||
|
||||
function addEmailField() {
|
||||
form.value.emails.push({})
|
||||
}
|
||||
|
||||
function deleteEmailField(index) {
|
||||
form.value.emails.splice(index, 1)
|
||||
}
|
||||
|
||||
function addPhoneNumberField() {
|
||||
form.value.phone_numbers.push({})
|
||||
}
|
||||
|
||||
function deletePhoneNumberField(index) {
|
||||
form.value.phone_numbers.splice(index, 1)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
working.value = true
|
||||
const data = JSON.parse(JSON.stringify(form.value))
|
||||
if (!Object.keys(data.phone_numbers[0]).length) {
|
||||
data.phone_numbers.splice(0, 1)
|
||||
}
|
||||
try {
|
||||
let resp
|
||||
if (props.contact) {
|
||||
resp = await contactsApi.updateContact(props.contact.pk, data)
|
||||
emit('updated', resp.data)
|
||||
} else {
|
||||
resp = await contactsApi.createContact(data)
|
||||
emit('added', resp.data)
|
||||
}
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
if (err.response?.status === 400) {
|
||||
formErrors.value = err.response.data
|
||||
}
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.contact,
|
||||
(value) => {
|
||||
if (value) {
|
||||
form.value = JSON.parse(JSON.stringify(value))
|
||||
if (!form.value.phone_numbers.length) {
|
||||
form.value.phone_numbers = [{}]
|
||||
}
|
||||
} else {
|
||||
form.value = initialForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<v-card class="mt-6">
|
||||
<v-data-table :headers="headers" :items="contacts">
|
||||
<template #top>
|
||||
<v-toolbar color="white" flat>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:placeholder="$gettext('Search')"
|
||||
variant="outlined"
|
||||
single-line
|
||||
flat
|
||||
hide-details
|
||||
density="compact"
|
||||
class="flex-grow-0 w-33 mr-4"
|
||||
></v-text-field>
|
||||
<v-btn color="primary" variant="outlined" @click="showForm = true">
|
||||
{{ $gettext('Add') }}
|
||||
</v-btn>
|
||||
<template v-if="preferences.enable_carddav_sync">
|
||||
<v-btn
|
||||
icon="mdi-information-outline"
|
||||
:title="$gettext('Display address book information')"
|
||||
class="ml-2"
|
||||
@click="showAddressBookInfo = true"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="!addressBook.last_sync"
|
||||
icon="mdi-sync"
|
||||
color="success"
|
||||
:title="$gettext('Synchronize contacts with address book')"
|
||||
@click="syncToAddressBook"
|
||||
/>
|
||||
</template>
|
||||
<v-chip
|
||||
v-if="$route.params.category"
|
||||
class="my-2 mx-4"
|
||||
label
|
||||
size="small"
|
||||
color="green"
|
||||
prepend-icon="mdi-label"
|
||||
@click="editCategory"
|
||||
>
|
||||
{{ $route.params.category }}
|
||||
</v-chip>
|
||||
<v-spacer></v-spacer>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
<template #[`item.emails`]="{ item }">
|
||||
{{ displayEmails(item.emails) }}
|
||||
</template>
|
||||
<template #[`item.phone_numbers`]="{ item }">
|
||||
{{ displayPhoneNumbers(item.phone_numbers) }}
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<div class="text-right">
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon="mdi-dots-horizontal" variant="text" v-bind="props">
|
||||
</v-btn>
|
||||
</template>
|
||||
<MenuItems :items="contactActions" :obj="item" />
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
<v-dialog v-model="showForm" persistent max-width="800px">
|
||||
<ContactForm
|
||||
:contact="selectedContact"
|
||||
@close="closeForm"
|
||||
@added="fetchContacts"
|
||||
@updated="fetchContacts"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showCategoriesForm" persistent max-width="800px">
|
||||
<ContactCategoriesForm
|
||||
:contact="selectedContact"
|
||||
@updated="fetchContacts"
|
||||
@close="closeCategoriesForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showAddressBookInfo" persistent max-width="800px">
|
||||
<AddressBookInfo
|
||||
:address-book-url="addressBook.url"
|
||||
@close="showAddressBookInfo = false"
|
||||
/>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import contactsApi from '@/api/contacts'
|
||||
import parametersApi from '@/api/parameters'
|
||||
import AddressBookInfo from './AddressBookInfo.vue'
|
||||
import ContactForm from './ContactForm.vue'
|
||||
import ContactCategoriesForm from './ContactCategoriesForm.vue'
|
||||
import MenuItems from '@/components/tools/MenuItems'
|
||||
|
||||
const emit = defineEmits(['editCategory'])
|
||||
|
||||
const route = useRoute()
|
||||
const { $gettext } = useGettext()
|
||||
const { displayNotification } = useBusStore()
|
||||
|
||||
const addressBook = ref({})
|
||||
const contacts = ref([])
|
||||
const search = ref('')
|
||||
const selectedContact = ref(null)
|
||||
const showAddressBookInfo = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showCategoriesForm = ref(false)
|
||||
const preferences = ref({})
|
||||
|
||||
const headers = [
|
||||
{ key: 'display_name', title: $gettext('Display name') },
|
||||
{ key: 'emails', title: $gettext('E-mail') },
|
||||
{ key: 'phone_numbers', title: $gettext('Phone') },
|
||||
{ key: 'actions', title: $gettext('Actions'), align: 'end' },
|
||||
]
|
||||
|
||||
const contactActions = [
|
||||
{
|
||||
label: $gettext('Categories'),
|
||||
icon: 'mdi-label',
|
||||
onClick: editCategories,
|
||||
},
|
||||
{
|
||||
label: $gettext('Edit'),
|
||||
icon: 'mdi-circle-edit-outline',
|
||||
onClick: editContact,
|
||||
},
|
||||
{
|
||||
label: $gettext('Delete'),
|
||||
icon: 'mdi-delete-outline',
|
||||
onClick: deleteContact,
|
||||
color: 'red',
|
||||
},
|
||||
]
|
||||
|
||||
let intervalId
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
})
|
||||
|
||||
function closeForm() {
|
||||
showForm.value = false
|
||||
selectedContact.value = null
|
||||
}
|
||||
|
||||
function displayEmails(emails) {
|
||||
return emails.map((email) => email.address).join(', ')
|
||||
}
|
||||
|
||||
function displayPhoneNumbers(phoneNumbers) {
|
||||
return phoneNumbers.map((phoneNumber) => phoneNumber.number).join(', ')
|
||||
}
|
||||
|
||||
function editContact(contact) {
|
||||
selectedContact.value = contact
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function editCategory() {
|
||||
emit('editCategory')
|
||||
}
|
||||
|
||||
function editCategories(contact) {
|
||||
selectedContact.value = contact
|
||||
showCategoriesForm.value = true
|
||||
}
|
||||
|
||||
function closeCategoriesForm() {
|
||||
showCategoriesForm.value = false
|
||||
selectedContact.value = null
|
||||
}
|
||||
|
||||
function deleteContact(contact) {
|
||||
contactsApi.deleteContact(contact.pk).then(() => {
|
||||
fetchContacts()
|
||||
})
|
||||
}
|
||||
|
||||
function fetchContacts() {
|
||||
contactsApi.getContacts(route.params).then((resp) => {
|
||||
contacts.value = resp.data
|
||||
})
|
||||
}
|
||||
|
||||
function syncToAddressBook() {
|
||||
contactsApi.synchronizeToAddressBook().then(() => {
|
||||
displayNotification({ msg: $gettext('Synchronization started') })
|
||||
})
|
||||
}
|
||||
|
||||
contactsApi.getDefaultAddressBook().then((resp) => {
|
||||
addressBook.value = resp.data
|
||||
})
|
||||
parametersApi.getUserApplication('contacts').then((resp) => {
|
||||
preferences.value = resp.data.params
|
||||
})
|
||||
fetchContacts()
|
||||
|
||||
watch(preferences, (value) => {
|
||||
if (value) {
|
||||
intervalId = setInterval(
|
||||
contactsApi.synchronizeFromAddressBook,
|
||||
value.sync_frequency * 1000
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.category,
|
||||
() => {
|
||||
fetchContacts()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="email.address"
|
||||
:label="$gettext('Address')"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-email"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="4" class="pl-4">
|
||||
<v-select
|
||||
v-model="email.type"
|
||||
:items="types"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="2" class="pl-4">
|
||||
<v-btn icon="mdi-plus" size="x-small" flat @click="$emit('add')" />
|
||||
<v-btn
|
||||
v-if="index"
|
||||
icon="mdi-trash-can"
|
||||
color="red"
|
||||
size="x-small"
|
||||
flat
|
||||
variant="text"
|
||||
@click="$emit('delete', index)"
|
||||
/>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['add', 'delete', 'update:modelValue'])
|
||||
|
||||
const email = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const types = ['home', 'work', 'other']
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="phone.number"
|
||||
:label="$gettext('Phone number')"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-phone"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="4" class="pl-4">
|
||||
<v-select
|
||||
v-model="phone.type"
|
||||
:items="types"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="2" class="pl-4">
|
||||
<v-btn icon="mdi-plus" size="x-small" flat @click="$emit('add')" />
|
||||
<v-btn
|
||||
v-if="index"
|
||||
icon="mdi-trash-can"
|
||||
color="red"
|
||||
size="x-small"
|
||||
flat
|
||||
variant="text"
|
||||
@click="$emit('delete', index)"
|
||||
/>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
errors: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['add', 'delete', 'update:modelValue'])
|
||||
|
||||
const phone = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const types = ['cell', 'fax', 'home', 'main', 'pager', 'work', 'other']
|
||||
</script>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:rail="rail"
|
||||
permanent
|
||||
:color="color"
|
||||
app
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-img
|
||||
src="@/assets/Modoboa_RVB-BLANC-SANS.png"
|
||||
max-width="190"
|
||||
class="logo"
|
||||
@click="router.push(props.logoRoute)"
|
||||
/>
|
||||
<v-btn
|
||||
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
|
||||
variant="text"
|
||||
@click.stop="rail = !rail"
|
||||
>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-list nav>
|
||||
<template v-for="item in menuItems" :key="item.text">
|
||||
<template v-if="displayMenuItem(item)">
|
||||
<v-list-subheader v-if="item.subheader" class="text-white">
|
||||
{{ item.text.toUpperCase() }}
|
||||
</v-list-subheader>
|
||||
<v-list-item
|
||||
v-else-if="item.action"
|
||||
:value="item"
|
||||
:exact="item.exact"
|
||||
:title="item.text"
|
||||
:prepend-icon="item.icon"
|
||||
@click="item.action"
|
||||
>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-else-if="!item.children"
|
||||
:value="item"
|
||||
:to="item.to"
|
||||
link
|
||||
:exact="item.exact"
|
||||
:title="item.text"
|
||||
:prepend-icon="item.icon"
|
||||
>
|
||||
<template v-if="item.withBell && displayInformationBell" #append>
|
||||
<v-icon color="red" icon="mdi-bell-alert-outline" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-group v-else :value="item.text">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:key="item.text"
|
||||
:title="item.text"
|
||||
color="white"
|
||||
:prepend-icon="item.icon"
|
||||
>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-for="subitem in item.children" :key="subitem.text">
|
||||
<template v-if="displayMenuItem(subitem)">
|
||||
<v-list-item
|
||||
v-if="subitem.action"
|
||||
:title="subitem.text"
|
||||
:value="subitem"
|
||||
:prepend-icon="subitem.icon"
|
||||
@click="subitem.action"
|
||||
></v-list-item>
|
||||
<v-list-item
|
||||
v-else
|
||||
:to="subitem.to"
|
||||
link
|
||||
:title="subitem.text"
|
||||
:value="subitem"
|
||||
:prepend-icon="subitem.icon"
|
||||
></v-list-item>
|
||||
</template>
|
||||
</template>
|
||||
</v-list-group>
|
||||
</template>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useGlobalStore, useAuthStore } from '@/stores'
|
||||
|
||||
const props = defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
menuItems: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
logoRoute: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const rail = ref(false)
|
||||
const drawer = ref(true)
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
|
||||
const displayInformationBell = computed(
|
||||
() =>
|
||||
globalStore.notifications.length !== undefined &&
|
||||
globalStore.notifications.length !== 0
|
||||
)
|
||||
|
||||
function displayMenuItem(item) {
|
||||
if (isAuthenticated.value) {
|
||||
const condition =
|
||||
(item.roles === undefined ||
|
||||
item.roles.indexOf(authUser.value.role) !== -1) &&
|
||||
(item.condition === undefined || item.condition()) &&
|
||||
item.activated !== false
|
||||
return condition
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (authUser.value.role === 'SuperAdmins') {
|
||||
globalStore.fetchNotifications()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list-item {
|
||||
&--active {
|
||||
&::before {
|
||||
opacity: 0;
|
||||
}
|
||||
background-color: #034bad !important;
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.v-list-subheader {
|
||||
&__text {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div v-if="user" class="top-menu">
|
||||
<v-menu activator="parent" location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon="mdi-apps" flat class="mr-4" v-bind="props"></v-btn>
|
||||
</template>
|
||||
<v-card min-width="350" max-width="400" class="pa-4">
|
||||
<v-row class="justify-center">
|
||||
<v-col
|
||||
v-for="application in applications"
|
||||
:key="application.name"
|
||||
cols="6"
|
||||
>
|
||||
<v-sheet
|
||||
rounded
|
||||
class="application text-center pa-4 text-body-2"
|
||||
@click="$router.push(application.url)"
|
||||
>
|
||||
<v-icon color="primary" :icon="application.icon" size="x-large" />
|
||||
<div class="mt-2">{{ application.label }}</div>
|
||||
</v-sheet>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-btn icon flat color="primary">
|
||||
{{ userInitials }}
|
||||
<v-menu activator="parent" location="bottom">
|
||||
<v-card min-width="300" max-width="350">
|
||||
<v-list>
|
||||
<div class="text-center">
|
||||
<v-avatar color="primary">
|
||||
<span class="text-h5">{{ userInitials }}</span>
|
||||
</v-avatar>
|
||||
<v-list-item :title="user.username" />
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
<v-list-item
|
||||
v-for="item in userMenuItems"
|
||||
:key="item.text"
|
||||
:to="item.to"
|
||||
:href="item.href"
|
||||
link
|
||||
@click="item.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :icon="item.icon"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import accountApi from '@/api/account'
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
remote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const applications = ref([])
|
||||
|
||||
const userInitials = computed(() => {
|
||||
return props.user.first_name && props.user.last_name
|
||||
? `${props.user.first_name[0].toUpperCase()}${props.user.last_name[0].toUpperCase()}`
|
||||
: props.user.username.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const userMenuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
text: $gettext('Account'),
|
||||
icon: 'mdi-account-circle-outline',
|
||||
to: !props.remote ? { name: 'AccountSettings' } : null,
|
||||
href: props.remote ? 'https://localhost:3000/account' : '',
|
||||
click: () => null,
|
||||
},
|
||||
{
|
||||
text: $gettext('Logout'),
|
||||
icon: 'mdi-logout',
|
||||
click: logout,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
getActivePinia()._s.forEach(async (store) => await store.$reset())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
accountApi.getAvailableApplications().then((resp) => {
|
||||
applications.value = resp.data
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.top-menu {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.application {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.application:hover {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<v-btn icon flat color="primary">
|
||||
{{ userInitials }}
|
||||
<v-menu activator="parent" location="bottom">
|
||||
<v-card min-width="300" max-width="350">
|
||||
<v-list>
|
||||
<div class="text-center">
|
||||
<v-avatar color="primary">
|
||||
<span class="text-h5">{{ userInitials }}</span>
|
||||
</v-avatar>
|
||||
<v-list-item :title="user.username" />
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!props.user) {
|
||||
return ''
|
||||
}
|
||||
return props.user.first_name && props.user.last_name
|
||||
? `${props.user.first_name[0].toUpperCase()}${props.user.last_name[0].toUpperCase()}`
|
||||
: props.user.username.slice(0, 2).toUpperCase()
|
||||
})
|
||||
</script>
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -43,7 +43,11 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
choicesPerLine: { type: Number, default: undefined },
|
||||
choicesPerLine: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<v-tabs v-model="active" bg-color="white">
|
||||
<v-tab
|
||||
v-for="(element, index) in displayableElements"
|
||||
:key="index"
|
||||
color="primary"
|
||||
:class="tab_error.includes(index) ? 'error_tab' : ''"
|
||||
>
|
||||
{{ element.label }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<v-tabs-window v-model="active">
|
||||
<v-tabs-window-item
|
||||
v-for="element in displayableElements"
|
||||
:key="element.label"
|
||||
>
|
||||
<v-container fluid class="pa-0">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<template
|
||||
v-for="param in displayableParams(element.parameters)"
|
||||
:key="param.name"
|
||||
>
|
||||
<div class="my-4">
|
||||
<template v-if="param.widget === 'SeparatorField'">
|
||||
<h2>{{ param.label }}</h2>
|
||||
</template>
|
||||
<template v-else-if="param.widget === 'BooleanField'">
|
||||
<v-switch
|
||||
v-if="param.widget === 'BooleanField'"
|
||||
v-model="parameters[param.name]"
|
||||
:label="param.label"
|
||||
:hint="param.help_text"
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
color="primary"
|
||||
density="compact"
|
||||
:error-messages="formErrors[param.name]"
|
||||
persistent-hint
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="m-label">{{ param.label }}</label>
|
||||
<v-select
|
||||
v-if="param.widget === 'ChoiceField'"
|
||||
v-model="parameters[param.name]"
|
||||
:items="param.choices"
|
||||
item-title="text"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="parameters[param.name]"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
:error-messages="formErrors[param.name]"
|
||||
:type="
|
||||
param.widget === 'PasswordField' ? 'password' : 'text'
|
||||
"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
<v-btn
|
||||
icon="mdi-content-save"
|
||||
class="ma-5"
|
||||
position="fixed"
|
||||
location="bottom right"
|
||||
color="green"
|
||||
size="large"
|
||||
@click="save"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
|
||||
const props = defineProps({
|
||||
app: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
structure: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
saveFunction: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { displayNotification } = useBusStore()
|
||||
|
||||
const displayableElements = computed(() =>
|
||||
props.structure.filter((element) => display(element))
|
||||
)
|
||||
|
||||
const active = ref(0)
|
||||
const formErrors = ref({})
|
||||
const parameters = ref({})
|
||||
const tab_error = ref([])
|
||||
|
||||
watch(
|
||||
() => props.values,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
parameters.value = { ...newValue }
|
||||
}
|
||||
}
|
||||
)
|
||||
watch(parameters, () => {
|
||||
tab_error.value = []
|
||||
})
|
||||
|
||||
function display(element) {
|
||||
if (element.display === '') {
|
||||
return true
|
||||
}
|
||||
let result = true
|
||||
element.display.split('&').forEach((rule) => {
|
||||
let [field, value] = rule.split('=')
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = Boolean(value)
|
||||
}
|
||||
result &= parameters.value[field] === value
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function displayableParams(params) {
|
||||
return params.filter((param) => display(param))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
formErrors.value = {}
|
||||
tab_error.value = []
|
||||
try {
|
||||
await props.saveFunction(props.app, parameters.value)
|
||||
displayNotification({
|
||||
msg: $gettext('Parameters updated'),
|
||||
})
|
||||
emit('success', parameters.value)
|
||||
} catch (error) {
|
||||
displayNotification({
|
||||
msg: $gettext('Could not save parameters'),
|
||||
type: 'error',
|
||||
})
|
||||
formErrors.value = error.response.data
|
||||
Object.keys(formErrors.value).forEach((element) => {
|
||||
for (let i = 0; i < props.structure.length; i++) {
|
||||
const isIn =
|
||||
props.structure[i].parameters.filter(
|
||||
(param) => param.name === element
|
||||
).length > 0
|
||||
if (isIn) {
|
||||
tab_error.value.push(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error_tab {
|
||||
color: #ff5252 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<ConnectedLayout color="grey" :menu-items="userSettingsMenuItems" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
|
||||
import parametersApi from '@/api/parameters'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const applications = ref([])
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
const userSettingsMenuItems = computed(() => {
|
||||
const result = []
|
||||
// Temporary solution...
|
||||
const appIcons = {
|
||||
contacts: 'mdi-contacts-outline',
|
||||
}
|
||||
|
||||
result.push({
|
||||
text: $gettext('Settings'),
|
||||
to: { name: 'AccountSettings' },
|
||||
icon: 'mdi-cog',
|
||||
})
|
||||
if (authUser.value.mailbox) {
|
||||
result.push({
|
||||
text: $gettext('Filters'),
|
||||
to: { name: 'AccountFilters' },
|
||||
icon: 'mdi-filter',
|
||||
})
|
||||
}
|
||||
for (const app of applications.value) {
|
||||
result.push({
|
||||
icon: appIcons[app.name],
|
||||
text: app.label,
|
||||
to: { name: 'AccountParametersEdit', params: { app: app.name } },
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
parametersApi.getUserApplications().then((resp) => {
|
||||
applications.value = resp.data
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<ConnectedLayout :menu-items="adminMenuItems" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useAuthStore, useParametersStore } from '@/stores'
|
||||
import parametersApi from '@/api/parameters'
|
||||
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const authStore = useAuthStore()
|
||||
const parametersStore = useParametersStore()
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
const imapMigrationEnabled = computed(
|
||||
() => parametersStore.imapMigrationEnabled
|
||||
)
|
||||
|
||||
const settings = []
|
||||
const adminMenuItems = [
|
||||
{
|
||||
text: $gettext('Dashboard'),
|
||||
to: { name: 'Dashboard' },
|
||||
exact: true,
|
||||
icon: 'mdi-view-dashboard-outline',
|
||||
},
|
||||
{
|
||||
text: $gettext('Domains'),
|
||||
to: { name: 'DomainList' },
|
||||
icon: 'mdi-domain',
|
||||
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Identities'),
|
||||
to: { name: 'Identities' },
|
||||
icon: 'mdi-account',
|
||||
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Alarms'),
|
||||
to: { name: 'Alarms' },
|
||||
icon: 'mdi-bell',
|
||||
},
|
||||
{
|
||||
icon: 'mdi-history',
|
||||
text: $gettext('Monitoring'),
|
||||
roles: ['SuperAdmins', 'Resellers', 'DomainAdmins'],
|
||||
children: [
|
||||
{
|
||||
text: $gettext('Statistics'),
|
||||
to: { name: 'Statistics' },
|
||||
roles: ['SuperAdmins'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Audit trail'),
|
||||
to: { name: 'AuditTrail' },
|
||||
roles: ['SuperAdmins'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Messages'),
|
||||
to: { name: 'MessageLog' },
|
||||
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'mdi-email-sync-outline',
|
||||
text: $gettext('IMAP Migration'),
|
||||
roles: ['SuperAdmins', 'Resellers'],
|
||||
condition: () => imapMigrationEnabled.value,
|
||||
children: [
|
||||
{
|
||||
text: $gettext('Email providers'),
|
||||
to: { name: 'ProvidersList' },
|
||||
roles: ['SuperAdmins', 'Resellers'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Migrations'),
|
||||
to: { name: 'MigrationsList' },
|
||||
roles: ['Resellers', 'SuperAdmins'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'mdi-cog',
|
||||
text: $gettext('Settings'),
|
||||
children: settings,
|
||||
roles: ['SuperAdmins'],
|
||||
},
|
||||
{
|
||||
icon: 'mdi-information',
|
||||
text: $gettext('Information'),
|
||||
roles: ['SuperAdmins'],
|
||||
to: { name: 'Information' },
|
||||
withBell: true,
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
if (authUser.value.role === 'SuperAdmins') {
|
||||
parametersApi.getGlobalApplications().then((response) => {
|
||||
response.data.forEach((item) => {
|
||||
settings.push({
|
||||
text: item.label,
|
||||
to: { name: 'ParametersEdit', params: { app: item.name } },
|
||||
})
|
||||
})
|
||||
})
|
||||
if (parametersStore.imapMigrationEnabled === null) {
|
||||
parametersApi.getGlobalApplication('imap_migration').then((response) => {
|
||||
parametersStore.imapMigrationEnabled =
|
||||
response.data.params.enabled_imapmigration
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
+22
-7
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<NavBar />
|
||||
<DashboardView />
|
||||
<NavBar :color="color" :menu-items="menuItems" />
|
||||
<TopMenu :user="authUser" />
|
||||
<ConnectedView />
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
:color="notificationColor"
|
||||
@@ -19,21 +20,35 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DashboardView from './DashboardView.vue'
|
||||
import NavBar from './NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useAuthStore, useBusStore } from '@/stores'
|
||||
import ConnectedView from './ConnectedView.vue'
|
||||
import NavBar from '@/components/shared/NavBar.vue'
|
||||
import TopMenu from '@/components/shared/TopMenu.vue'
|
||||
|
||||
import { useBusStore } from '@/stores'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const busStore = useBusStore()
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const props = defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
const notificationColor = computed(() => busStore.notificationColor)
|
||||
const notificationTimeout = 2000
|
||||
const notification = computed(() => busStore.notification)
|
||||
const snackbar = ref(false)
|
||||
|
||||
const notificationTimeout = 2000
|
||||
|
||||
busStore.$onAction(({ name, after }) => {
|
||||
if (name === 'displayNotification') {
|
||||
after(() => {
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
<template>
|
||||
<v-main>
|
||||
<v-container fluid>
|
||||
<v-container fluid style="height: 100%">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade">
|
||||
<div v-show="true">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</v-container>
|
||||
@@ -1,353 +0,0 @@
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:rail="rail"
|
||||
permanent
|
||||
:color="mainColor"
|
||||
app
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-img
|
||||
src="@/assets/Modoboa_RVB-BLANC-SANS.png"
|
||||
max-width="190"
|
||||
class="logo"
|
||||
@click="router.push({ name: 'Dashboard' })"
|
||||
/>
|
||||
<v-btn
|
||||
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
|
||||
variant="text"
|
||||
@click.stop="rail = !rail"
|
||||
>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-list nav>
|
||||
<template v-for="item in menuItems" :key="item.text">
|
||||
<template v-if="displayMenuItem(item)">
|
||||
<v-list-item
|
||||
v-if="!item.children"
|
||||
:value="item"
|
||||
:to="item.to"
|
||||
link
|
||||
:exact="item.exact"
|
||||
:title="item.text"
|
||||
:prepend-icon="item.icon"
|
||||
>
|
||||
<template v-if="item.withBell && displayInformationBell" #append>
|
||||
<v-icon color="red" icon="mdi-bell-alert-outline" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-group v-else :value="item.text">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:key="item.text"
|
||||
:title="item.text"
|
||||
color="white"
|
||||
:prepend-icon="item.icon"
|
||||
>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-for="subitem in item.children" :key="subitem.text">
|
||||
<v-list-item
|
||||
v-if="displayMenuItem(subitem)"
|
||||
:to="subitem.to"
|
||||
link
|
||||
:title="subitem.text"
|
||||
:value="subitem"
|
||||
></v-list-item>
|
||||
</template>
|
||||
</v-list-group>
|
||||
</template>
|
||||
</template>
|
||||
</v-list>
|
||||
<template #append>
|
||||
<v-menu v-if="isAuthenticated" rounded="lg" offset-y top>
|
||||
<template #activator="{ props }">
|
||||
<div
|
||||
:class="[
|
||||
backgroundColor,
|
||||
'd-flex',
|
||||
'justify-center',
|
||||
'align-center',
|
||||
'pa-2',
|
||||
]"
|
||||
>
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
:class="[backgroundColor, 'text-white']"
|
||||
rounded
|
||||
density="compact"
|
||||
height="48"
|
||||
elevation="0"
|
||||
>
|
||||
<v-avatar size="40" color="primary">
|
||||
<span class="text-white headline">{{ userInitials }}</span>
|
||||
</v-avatar>
|
||||
<template v-if="!rail">
|
||||
<span class="mx-2">{{ displayName }}</span>
|
||||
<v-icon class="float-right">mdi-chevron-up</v-icon>
|
||||
</template>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="item in userMenuItems"
|
||||
:key="item.text"
|
||||
:to="item.to"
|
||||
link
|
||||
@click="item.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :icon="item.icon"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { useGlobalStore, useAuthStore, useParametersStore } from '@/stores'
|
||||
import parametersApi from '@/api/parameters'
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
const authStore = useAuthStore()
|
||||
const parametersStore = useParametersStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const rail = ref(false)
|
||||
const drawer = ref(true)
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const backgroundColor = computed(() =>
|
||||
route.meta.layout === 'account' ? 'bg-grey-darken-1' : 'bg-primary-darken-1'
|
||||
)
|
||||
const userInitials = computed(() => {
|
||||
let initials = null
|
||||
if (authUser.value.first_name) {
|
||||
initials = authUser.value.first_name[0]
|
||||
}
|
||||
if (authUser.value.last_name) {
|
||||
initials = initials
|
||||
? initials + authUser.value.last_name[0]
|
||||
: authUser.value.last_name[0]
|
||||
}
|
||||
if (!initials) {
|
||||
initials = authUser.value.username[0]
|
||||
}
|
||||
return initials
|
||||
})
|
||||
const displayName = computed(() => {
|
||||
return authUser.value.first_name || authUser.value.last_name
|
||||
? `${authUser.value.first_name} ${authUser.value.last_name}`
|
||||
: authUser.value.username
|
||||
})
|
||||
|
||||
const menuItems = computed(() => {
|
||||
if (route.meta.layout === 'account') {
|
||||
return getUserSettingsMenuItems()
|
||||
}
|
||||
return mainMenuItems
|
||||
})
|
||||
|
||||
const mainColor = computed(() => {
|
||||
if (route.meta.layout === 'account') {
|
||||
return 'grey'
|
||||
}
|
||||
return 'primary'
|
||||
})
|
||||
|
||||
const imapMigration = computed(() => parametersStore.imapMigrationEnabled)
|
||||
|
||||
const displayInformationBell = computed(
|
||||
() =>
|
||||
globalStore.notifications.length !== undefined &&
|
||||
globalStore.notifications.length !== 0
|
||||
)
|
||||
|
||||
const settings = []
|
||||
const mainMenuItems = [
|
||||
{
|
||||
text: $gettext('Dashboard'),
|
||||
to: { name: 'Dashboard' },
|
||||
exact: true,
|
||||
icon: 'mdi-view-dashboard-outline',
|
||||
},
|
||||
{
|
||||
text: $gettext('Domains'),
|
||||
to: { name: 'DomainList' },
|
||||
icon: 'mdi-domain',
|
||||
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Identities'),
|
||||
to: { name: 'Identities' },
|
||||
icon: 'mdi-account',
|
||||
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Alarms'),
|
||||
to: { name: 'Alarms' },
|
||||
icon: 'mdi-bell',
|
||||
},
|
||||
{
|
||||
icon: 'mdi-history',
|
||||
text: $gettext('Monitoring'),
|
||||
roles: ['SuperAdmins', 'Resellers', 'DomainAdmins'],
|
||||
children: [
|
||||
{
|
||||
text: $gettext('Statistics'),
|
||||
to: { name: 'Statistics' },
|
||||
roles: ['SuperAdmins'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Audit trail'),
|
||||
to: { name: 'AuditTrail' },
|
||||
roles: ['SuperAdmins'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Messages'),
|
||||
to: { name: 'MessageLog' },
|
||||
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'mdi-email-sync-outline',
|
||||
text: $gettext('IMAP Migration'),
|
||||
roles: ['SuperAdmins', 'Resellers'],
|
||||
children: [
|
||||
{
|
||||
text: $gettext('Email providers'),
|
||||
to: { name: 'ProvidersList' },
|
||||
roles: ['SuperAdmins', 'Resellers'],
|
||||
},
|
||||
{
|
||||
text: $gettext('Migrations'),
|
||||
to: { name: 'MigrationsList' },
|
||||
roles: ['Resellers', 'SuperAdmins'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'mdi-cog',
|
||||
text: $gettext('Settings'),
|
||||
children: settings,
|
||||
roles: ['SuperAdmins'],
|
||||
},
|
||||
{
|
||||
icon: 'mdi-information',
|
||||
text: $gettext('Information'),
|
||||
roles: ['SuperAdmins'],
|
||||
to: { name: 'Information' },
|
||||
withBell: true,
|
||||
},
|
||||
]
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
text: $gettext('Account'),
|
||||
icon: 'mdi-account-circle-outline',
|
||||
to: { name: 'AccountSettings' },
|
||||
click: () => null,
|
||||
},
|
||||
{
|
||||
text: $gettext('Logout'),
|
||||
icon: 'mdi-logout',
|
||||
click: logout,
|
||||
},
|
||||
]
|
||||
|
||||
if (isAuthenticated.value && authUser.value.role !== 'SimpleUsers') {
|
||||
userMenuItems.unshift({
|
||||
text: $gettext('Admin'),
|
||||
to: { name: 'Dashboard' },
|
||||
icon: 'mdi-view-dashboard-outline',
|
||||
})
|
||||
}
|
||||
|
||||
function getUserSettingsMenuItems() {
|
||||
const result = []
|
||||
|
||||
if (authUser.value.mailbox) {
|
||||
result.push({
|
||||
text: $gettext('Filters'),
|
||||
to: { name: 'AccountFilters' },
|
||||
icon: 'mdi-filter',
|
||||
})
|
||||
}
|
||||
result.push({
|
||||
text: $gettext('Settings'),
|
||||
to: { name: 'AccountSettings' },
|
||||
icon: 'mdi-cog',
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function displayMenuItem(item) {
|
||||
if (isAuthenticated.value) {
|
||||
const condition =
|
||||
(item.roles === undefined ||
|
||||
item.roles.indexOf(authUser.value.role) !== -1) &&
|
||||
(item.condition === undefined || item.condition()) &&
|
||||
item.activated !== false
|
||||
if (item.icon === 'mdi-email-sync-outline') {
|
||||
// For imapMigration
|
||||
return condition && imapMigration.value
|
||||
}
|
||||
return condition
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
getActivePinia()._s.forEach(async (store) => await store.$reset())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (authUser.value.role === 'SuperAdmins') {
|
||||
parametersApi.getApplications().then((response) => {
|
||||
response.data.forEach((item) => {
|
||||
settings.push({
|
||||
text: item.label,
|
||||
to: { name: 'ParametersEdit', params: { app: item.name } },
|
||||
})
|
||||
})
|
||||
})
|
||||
globalStore.fetchNotifications()
|
||||
if (parametersStore.imapMigrationEnabled === null) {
|
||||
parametersApi.getApplication('imap_migration').then((response) => {
|
||||
parametersStore.imapMigrationEnabled =
|
||||
response.data.params.enabled_imapmigration
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list-item {
|
||||
&--active {
|
||||
&::before {
|
||||
opacity: 0;
|
||||
}
|
||||
background-color: #034bad !important;
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<DefaultView />
|
||||
<EmptyView />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DefaultView from './DefaultView.vue'
|
||||
import EmptyView from './EmptyView.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<ConnectedLayout :menu-items="layoutStore.leftMenuItems" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLayoutStore } from '@/stores'
|
||||
import ConnectedLayout from '@/layouts/connected/ConnectedLayout.vue'
|
||||
|
||||
const layoutStore = useLayoutStore()
|
||||
</script>
|
||||
+438
-129
File diff suppressed because it is too large
Load Diff
+393
-111
File diff suppressed because it is too large
Load Diff
+415
-111
File diff suppressed because it is too large
Load Diff
+392
-111
File diff suppressed because it is too large
Load Diff
+392
-111
File diff suppressed because it is too large
Load Diff
+438
-130
File diff suppressed because it is too large
Load Diff
+420
-112
File diff suppressed because it is too large
Load Diff
+432
-116
File diff suppressed because it is too large
Load Diff
+434
-128
File diff suppressed because it is too large
Load Diff
+391
-111
File diff suppressed because it is too large
Load Diff
+387
-111
File diff suppressed because it is too large
Load Diff
+392
-111
File diff suppressed because it is too large
Load Diff
+393
-111
File diff suppressed because it is too large
Load Diff
+426
-112
File diff suppressed because it is too large
Load Diff
+415
-111
File diff suppressed because it is too large
Load Diff
+421
-115
File diff suppressed because it is too large
Load Diff
+405
-111
File diff suppressed because it is too large
Load Diff
+392
-111
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import vuetify from './vuetify'
|
||||
import router from '../router'
|
||||
import gettext from './gettext'
|
||||
import filters from './filters'
|
||||
import permissions from './permissions'
|
||||
import pinia from '@/stores'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
@@ -19,5 +20,6 @@ export function registerPlugins(app) {
|
||||
.use(pinia)
|
||||
.use(router)
|
||||
.use(filters)
|
||||
.use(permissions)
|
||||
.use(VueApexCharts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
export default {
|
||||
install: (app) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
app.directive('can', (el, binding) => {
|
||||
const permission = binding.value
|
||||
if (authStore.authUser) {
|
||||
if (
|
||||
authUser.authUser.role !== 'SuperAdmins' &&
|
||||
authStore.authUser.permissions.indexOf(permission) === -1
|
||||
) {
|
||||
el.style.display = 'none'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.config.globalProperties.$can = function (permission) {
|
||||
if (!authStore.authUser) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
authStore.authUser.role === 'SuperAdmins' ||
|
||||
authStore.authUser.permissions.indexOf(permission) !== -1
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -24,7 +24,11 @@ function samePassword(value, confirmation) {
|
||||
|
||||
export default {
|
||||
required: (value) =>
|
||||
(value !== '' && value != null) || $gettext('Field is required'),
|
||||
(value !== '' &&
|
||||
value != null &&
|
||||
value !== undefined &&
|
||||
(!Array.isArray(value) || value.length)) ||
|
||||
$gettext('Field is required'),
|
||||
email: (value) => validateEmail(value),
|
||||
emailOrNull: (value) => value != null || value !== '' || validateEmail(value),
|
||||
minLength: (len) => (value) =>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores'
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
component: () => import('@/layouts/default/DefaultLayout.vue'),
|
||||
component: () => import('@/layouts/empty/EmptyLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -16,24 +16,27 @@ const routes = [
|
||||
path: 'logged',
|
||||
name: 'LoginCallback',
|
||||
component: () => import('@/views/login/LoginCallbackView.vue'),
|
||||
props: {
|
||||
redirectUrl: '/admin',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/dashboard/DashboardLayout.vue'),
|
||||
component: () => import('@/layouts/admin/AdminLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'domains',
|
||||
component: () => import('@/layouts/default/DefaultLayout.vue'),
|
||||
component: () => import('@/layouts/empty/EmptyLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
@@ -70,7 +73,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: 'imap_migration',
|
||||
component: () => import('@/layouts/default/DefaultLayout.vue'),
|
||||
component: () => import('@/layouts/empty/EmptyLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
@@ -88,7 +91,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: 'providers',
|
||||
component: () => import('@/layouts/default/DefaultLayout.vue'),
|
||||
component: () => import('@/layouts/empty/EmptyLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['Resellers', 'SuperAdmins'],
|
||||
@@ -120,7 +123,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: 'identities',
|
||||
component: () => import('@/layouts/default/DefaultLayout.vue'),
|
||||
component: () => import('@/layouts/empty/EmptyLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins'],
|
||||
@@ -233,9 +236,8 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
component: () => import('@/layouts/dashboard/DashboardLayout.vue'),
|
||||
component: () => import('@/layouts/account/AccountLayout.vue'),
|
||||
meta: {
|
||||
layout: 'account',
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
@@ -244,22 +246,77 @@ const routes = [
|
||||
name: 'AccountFilters',
|
||||
component: () => import('@/views/account/FiltersView.vue'),
|
||||
meta: {
|
||||
layout: 'account',
|
||||
requiresAuth: true,
|
||||
requiresMailbox: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'parameters/:app',
|
||||
name: 'AccountParametersEdit',
|
||||
component: () => import('@/views/account/ParametersView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':tab?',
|
||||
name: 'AccountSettings',
|
||||
component: () => import('@/views/account/SettingsView.vue'),
|
||||
meta: {
|
||||
layout: 'account',
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: () => import('@/layouts/user/UserLayout.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'UserDashboard',
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: ':category?',
|
||||
name: 'ContactList',
|
||||
component: () => import('@/views/contacts/AddressBook.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'calendars',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'CalendarView',
|
||||
component: () => import('@/views/calendars/CalendarView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import gettext from '@/plugins/gettext'
|
||||
import router from '@/router/index.js'
|
||||
import { UserManager } from 'oidc-client-ts'
|
||||
@@ -130,7 +130,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function completeLogin() {
|
||||
async function completeLogin(redirectUrl) {
|
||||
try {
|
||||
const user = await manager.signinRedirectCallback()
|
||||
isAuthenticated.value = true
|
||||
@@ -140,7 +140,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
window.location.href = previousPage
|
||||
} else {
|
||||
// Redirect to a default page if the previous page is not available
|
||||
router.push({ name: 'Dashboard' })
|
||||
router.push(redirectUrl)
|
||||
}
|
||||
return user
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useContactsStore = defineStore('contacts', () => {
|
||||
const currentCategory = ref(null)
|
||||
|
||||
function setCurrentCategory(category) {
|
||||
currentCategory.value = category
|
||||
}
|
||||
|
||||
async function $reset() {
|
||||
currentCategory.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
currentCategory,
|
||||
setCurrentCategory,
|
||||
$reset,
|
||||
}
|
||||
})
|
||||
@@ -14,5 +14,9 @@ export const useGlobalStore = defineStore('global', () => {
|
||||
return notifications.value.find((item) => item.id === id)
|
||||
}
|
||||
|
||||
return { notifications, fetchNotifications, getNotificationById }
|
||||
async function $reset() {
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
return { notifications, fetchNotifications, getNotificationById, $reset }
|
||||
})
|
||||
|
||||
@@ -3,11 +3,13 @@ import { createPinia } from 'pinia'
|
||||
export * from './global.store'
|
||||
export * from './auth.store'
|
||||
export * from './bus.store'
|
||||
export * from './layout.store'
|
||||
export * from './domains.store'
|
||||
export * from './accounts.store'
|
||||
export * from './aliases.store'
|
||||
export * from './identities.store'
|
||||
export * from './parameters.store'
|
||||
export * from './providers.store'
|
||||
export * from './contacts.store'
|
||||
|
||||
export default createPinia()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useLayoutStore = defineStore('layout', () => {
|
||||
const leftMenuItems = ref([])
|
||||
|
||||
function setLeftMenuItems(items) {
|
||||
leftMenuItems.value = items
|
||||
}
|
||||
|
||||
async function $reset() {
|
||||
leftMenuItems.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
leftMenuItems,
|
||||
setLeftMenuItems,
|
||||
$reset,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>{{ title }}</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<ParametersForm
|
||||
:app="$route.params.app"
|
||||
:structure="structure"
|
||||
:values="parameters"
|
||||
:save-function="parametersApi.saveUserApplication"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ParametersForm from '@/components/tools/ParametersForm'
|
||||
import parametersApi from '@/api/parameters'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const route = useRoute()
|
||||
|
||||
const label = ref('')
|
||||
const structure = ref([])
|
||||
const parameters = ref({})
|
||||
|
||||
const title = computed(() => $gettext('Settings: ' + label.value))
|
||||
|
||||
function loadParams(app) {
|
||||
parametersApi.getUserApplicationStructure(app).then((response) => {
|
||||
structure.value = response.data
|
||||
})
|
||||
parametersApi.getUserApplication(app).then((response) => {
|
||||
parameters.value = response.data.params
|
||||
label.value = response.data.label
|
||||
})
|
||||
}
|
||||
|
||||
loadParams(route.params.app)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
background-color: #f7f8fa !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.v-window__container {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
@@ -4,188 +4,57 @@
|
||||
<v-toolbar-title>{{ title }}</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<v-tabs v-model="active" bg-color="white">
|
||||
<v-tab
|
||||
v-for="(element, index) in displayableElements"
|
||||
:key="index"
|
||||
color="primary"
|
||||
:class="tab_error.includes(index) ? 'error_tab' : ''"
|
||||
>
|
||||
{{ element.label }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<v-tabs-window v-model="active">
|
||||
<v-tabs-window-item
|
||||
v-for="element in displayableElements"
|
||||
:key="element.label"
|
||||
>
|
||||
<v-container fluid class="pa-0">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<template
|
||||
v-for="param in displayableParams(element.parameters)"
|
||||
:key="param.name"
|
||||
>
|
||||
<div class="my-4">
|
||||
<template v-if="param.widget === 'SeparatorField'">
|
||||
<h2>{{ param.label }}</h2>
|
||||
</template>
|
||||
<template v-else-if="param.widget === 'BooleanField'">
|
||||
<v-switch
|
||||
v-if="param.widget === 'BooleanField'"
|
||||
v-model="parameters[param.name]"
|
||||
:label="param.label"
|
||||
:hint="param.help_text"
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
color="primary"
|
||||
density="compact"
|
||||
:error-messages="formErrors[param.name]"
|
||||
persistent-hint
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="m-label">{{ param.label }}</label>
|
||||
<v-select
|
||||
v-if="param.widget === 'ChoiceField'"
|
||||
v-model="parameters[param.name]"
|
||||
:items="param.choices"
|
||||
item-title="text"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="parameters[param.name]"
|
||||
:hint="param.help_text"
|
||||
persistent-hint
|
||||
:error="formErrors[param.name] !== undefined"
|
||||
:error-messages="formErrors[param.name]"
|
||||
:type="
|
||||
param.widget === 'PasswordField' ? 'password' : 'text'
|
||||
"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
<v-btn
|
||||
icon="mdi-content-save"
|
||||
class="ma-5"
|
||||
position="fixed"
|
||||
location="bottom right"
|
||||
color="green"
|
||||
size="large"
|
||||
@click="save"
|
||||
<ParametersForm
|
||||
:app="$route.params.app"
|
||||
:structure="structure"
|
||||
:values="parameters"
|
||||
:save-function="parametersApi.saveGlobalApplication"
|
||||
@success="onSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
<script setup>
|
||||
import { useBusStore } from '@/stores'
|
||||
import parametersApi from '@/api/parameters'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGlobalStore, useParametersStore } from '@/stores'
|
||||
import ParametersForm from '@/components/tools/ParametersForm'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const route = useRoute()
|
||||
const parametersStore = useParametersStore()
|
||||
|
||||
const active = ref(0)
|
||||
const structure = ref([])
|
||||
const parameters = ref({})
|
||||
const formErrors = ref({})
|
||||
const label = ref('')
|
||||
const tab_error = ref([])
|
||||
|
||||
const displayableElements = computed(() =>
|
||||
structure.value.filter((element) => display(element))
|
||||
)
|
||||
|
||||
const title = computed(() => $gettext('Settings: ' + label.value))
|
||||
|
||||
function loadParams(app) {
|
||||
parametersApi.getApplicationStructure(app).then((response) => {
|
||||
parametersApi.getGlobalApplicationStructure(app).then((response) => {
|
||||
structure.value = response.data
|
||||
})
|
||||
parametersApi.getApplication(app).then((response) => {
|
||||
parametersApi.getGlobalApplication(app).then((response) => {
|
||||
parameters.value = response.data.params
|
||||
label.value = response.data.label
|
||||
})
|
||||
}
|
||||
|
||||
function display(element) {
|
||||
if (element.display === '') {
|
||||
return true
|
||||
function onSuccess(newValues) {
|
||||
globalStore.fetchNotifications()
|
||||
if (route.params.app === 'imap_migration') {
|
||||
parametersStore.imapMigrationEnabled = newValues.enabled_imapmigration
|
||||
}
|
||||
let result = true
|
||||
element.display.split('&').forEach((rule) => {
|
||||
let [field, value] = rule.split('=')
|
||||
if (value === 'true' || value === 'false') {
|
||||
value = Boolean(value)
|
||||
}
|
||||
result &= parameters.value[field] === value
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function displayableParams(params) {
|
||||
return params.filter((param) => display(param))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
formErrors.value = {}
|
||||
tab_error.value = []
|
||||
parametersApi
|
||||
.saveApplication(route.params.app, parameters.value)
|
||||
.then(() => {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Parameters updated'),
|
||||
})
|
||||
globalStore.fetchNotifications()
|
||||
if (route.params.app === 'imap_migration') {
|
||||
parametersStore.imapMigrationEnabled =
|
||||
parameters.value.enabled_imapmigration
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Could not save parameters'),
|
||||
type: 'error',
|
||||
})
|
||||
formErrors.value = error.response.data
|
||||
Object.keys(formErrors.value).forEach((element) => {
|
||||
for (let i = 0; i < structure.value.length; i++) {
|
||||
const isIn =
|
||||
structure.value[i].parameters.filter(
|
||||
(param) => param.name === element
|
||||
).length > 0
|
||||
if (isIn) {
|
||||
tab_error.value.push(i)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(route, (toRoute) => {
|
||||
loadParams(toRoute.params.app)
|
||||
})
|
||||
|
||||
watch(parameters, () => {
|
||||
tab_error.value = []
|
||||
})
|
||||
loadParams(route.params.app)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,44 +2,47 @@
|
||||
<div>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>{{ $gettext('Domains') }}</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
:title="$gettext('Import domains and aliases from CSV file')"
|
||||
variant="elevated"
|
||||
icon="mdi-file-import-outline"
|
||||
@click="showImportForm = true"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
:title="$gettext('Export domains and aliases to CSV')"
|
||||
variant="elevated"
|
||||
icon="mdi-file-export-outline"
|
||||
@click="exportDomains"
|
||||
></v-btn>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
>
|
||||
{{ $gettext('New') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="showDomainWizard = true">
|
||||
<v-list-item-title>{{ $gettext('Domain') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="showAliasForm = true">
|
||||
<v-list-item-title>{{ $gettext('Alias') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-toolbar>
|
||||
|
||||
<DomainList />
|
||||
<DomainList>
|
||||
<template #extraActions>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
class="mr-4"
|
||||
>
|
||||
{{ $gettext('New') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="showDomainWizard = true">
|
||||
<v-list-item-title>{{ $gettext('Domain') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="showAliasForm = true">
|
||||
<v-list-item-title>{{ $gettext('Alias') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
:title="$gettext('Import domains and aliases from CSV file')"
|
||||
variant="flat"
|
||||
icon="mdi-file-import-outline"
|
||||
@click="showImportForm = true"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
:title="$gettext('Export domains and aliases to CSV')"
|
||||
variant="flat"
|
||||
icon="mdi-file-export-outline"
|
||||
@click="exportDomains"
|
||||
></v-btn>
|
||||
</template>
|
||||
</DomainList>
|
||||
|
||||
<v-dialog v-model="showDomainWizard" fullscreen scrollable z-index="10">
|
||||
<DomainCreationForm @close="showDomainWizard = false" />
|
||||
|
||||
@@ -62,7 +62,7 @@ const account = computed(() => {
|
||||
})
|
||||
const limitsConfig = ref({})
|
||||
|
||||
parametersApi.getApplication('limits').then((resp) => {
|
||||
parametersApi.getGlobalApplication('limits').then((resp) => {
|
||||
limitsConfig.value = resp.data
|
||||
})
|
||||
|
||||
|
||||
@@ -2,44 +2,48 @@
|
||||
<div>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>{{ $gettext('Identities') }}</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
:title="$gettext('Import accounts and aliases from CSV file')"
|
||||
variant="elevated"
|
||||
icon="mdi-file-import-outline"
|
||||
@click="showImportForm = true"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
:title="$gettext('Export accounts and aliases to CSV')"
|
||||
icon="mdi-file-export-outline"
|
||||
variant="elevated"
|
||||
@click="exportIdentities"
|
||||
></v-btn>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
>
|
||||
{{ $gettext('New') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="showCreationWizard = true">
|
||||
<v-list-item-title>{{ $gettext('Account') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="showAliasCreationWizard = true">
|
||||
<v-list-item-title>{{ $gettext('Alias') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-toolbar>
|
||||
|
||||
<IdentityList />
|
||||
<IdentityList>
|
||||
<template #extraActions>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
class="mr-4"
|
||||
>
|
||||
{{ $gettext('New') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="showCreationWizard = true">
|
||||
<v-list-item-title>{{ $gettext('Account') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="showAliasCreationWizard = true">
|
||||
<v-list-item-title>{{ $gettext('Alias') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
:title="$gettext('Import accounts and aliases from CSV file')"
|
||||
variant="flat"
|
||||
icon="mdi-file-import-outline"
|
||||
@click="showImportForm = true"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
class="mr-2"
|
||||
:title="$gettext('Export accounts and aliases to CSV')"
|
||||
icon="mdi-file-export-outline"
|
||||
variant="flat"
|
||||
@click="exportIdentities"
|
||||
></v-btn>
|
||||
</template>
|
||||
</IdentityList>
|
||||
<v-dialog
|
||||
v-model="showAliasCreationWizard"
|
||||
fullscreen
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div class="text-h5 ml-4 mb-6">
|
||||
{{ $gettext('Calendar') }}
|
||||
</div>
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
|
||||
<v-dialog v-model="showInformation" persistent max-width="800px">
|
||||
<CalendarDetail :calendar="selectedCalendar" @close="closeInformation" />
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showCalendarForm" persistent max-width="800px">
|
||||
<CalendarForm
|
||||
:initial-calendar="selectedCalendar"
|
||||
@color-changed="refreshCalendarEvents"
|
||||
@close="closeCalendarForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showEventForm" persistent max-width="800px">
|
||||
<EventForm
|
||||
:info="selectInfo"
|
||||
:event="selectedEvent"
|
||||
@close="closeEventForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showAccessRulesForm" persistent max-width="800px">
|
||||
<CalendarAccessRulesForm
|
||||
v-if="selectedCalendar"
|
||||
:calendar-pk="selectedCalendar.pk"
|
||||
@close="closeAccessRulesForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showImportEventsForm" persistent max-width="800px">
|
||||
<ImportEventsForm
|
||||
v-if="selectedCalendar"
|
||||
:calendar="selectedCalendar"
|
||||
@events-imported="refreshCalendarEvents"
|
||||
@close="closeImportEventsForm"
|
||||
/>
|
||||
</v-dialog>
|
||||
<ConfirmDialog ref="confirm" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useLayoutStore } from '@/stores'
|
||||
import { useBusStore } from '@/stores'
|
||||
import { DateTime } from 'luxon'
|
||||
import api from '@/api/calendars'
|
||||
import CalendarAccessRulesForm from '@/components/calendars/CalendarAccessRulesForm'
|
||||
import CalendarForm from '@/components/calendars/CalendarForm'
|
||||
import CalendarDetail from '@/components/calendars/CalendarDetail'
|
||||
import ConfirmDialog from '@/components/tools/ConfirmDialog'
|
||||
import EventForm from '@/components/calendars/EventForm'
|
||||
import ImportEventsForm from '@/components/calendars/ImportEventsForm'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
|
||||
const { $gettext, current } = useGettext()
|
||||
const layoutStore = useLayoutStore()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const calendarRef = ref()
|
||||
const confirm = ref()
|
||||
const selectInfo = ref()
|
||||
const selectedCalendar = ref(null)
|
||||
const selectedEvent = ref(null)
|
||||
const userCalendars = ref([])
|
||||
const showAccessRulesForm = ref(false)
|
||||
const showCalendarForm = ref(false)
|
||||
const showInformation = ref(false)
|
||||
const showEventForm = ref(false)
|
||||
const showImportEventsForm = ref(false)
|
||||
|
||||
const leftMenuItems = computed(() => {
|
||||
const result = [
|
||||
{
|
||||
icon: 'mdi-plus',
|
||||
text: $gettext('New calendar'),
|
||||
action: openCalendarForm,
|
||||
},
|
||||
{
|
||||
text: $gettext('My calendars'),
|
||||
subheader: true,
|
||||
},
|
||||
]
|
||||
for (const calendar of userCalendars.value) {
|
||||
result.push({
|
||||
text: calendar.name,
|
||||
children: [
|
||||
{
|
||||
text: $gettext('Information'),
|
||||
icon: 'mdi-information-outline',
|
||||
action: () => openInformation(calendar),
|
||||
},
|
||||
{
|
||||
text: $gettext('Edit'),
|
||||
icon: 'mdi-pencil',
|
||||
action: () => editCalendar(calendar),
|
||||
},
|
||||
{
|
||||
text: $gettext('Access rules'),
|
||||
icon: 'mdi-filter-outline',
|
||||
action: () => openAccessRulesForm(calendar),
|
||||
},
|
||||
{
|
||||
text: $gettext('Delete'),
|
||||
icon: 'mdi-trash-can-outline',
|
||||
action: () => deleteCalendar(calendar.pk),
|
||||
},
|
||||
{
|
||||
text: $gettext('Import'),
|
||||
icon: 'mdi-calendar-import-outline',
|
||||
action: () => openImportEventsForm(calendar),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function fetchUserCalendarEvents(
|
||||
calendarPk,
|
||||
info,
|
||||
successCallback,
|
||||
failureCallback
|
||||
) {
|
||||
try {
|
||||
const resp = await api.getUserCalendarEvents(calendarPk, {
|
||||
start: info.startStr,
|
||||
end: info.endStr,
|
||||
})
|
||||
successCallback(resp.data)
|
||||
} catch (err) {
|
||||
failureCallback(err)
|
||||
}
|
||||
}
|
||||
|
||||
function openCalendarForm() {
|
||||
showCalendarForm.value = true
|
||||
}
|
||||
|
||||
function closeCalendarForm() {
|
||||
selectedCalendar.value = null
|
||||
showCalendarForm.value = false
|
||||
fetchUserCalendars()
|
||||
}
|
||||
|
||||
function openInformation(calendar) {
|
||||
selectedCalendar.value = calendar
|
||||
showInformation.value = true
|
||||
}
|
||||
|
||||
function closeInformation() {
|
||||
selectedCalendar.value = null
|
||||
showInformation.value = false
|
||||
}
|
||||
|
||||
function openAccessRulesForm(calendar) {
|
||||
selectedCalendar.value = calendar
|
||||
showAccessRulesForm.value = true
|
||||
}
|
||||
|
||||
function closeAccessRulesForm() {
|
||||
selectedCalendar.value = null
|
||||
showAccessRulesForm.value = false
|
||||
}
|
||||
|
||||
function openImportEventsForm(calendar) {
|
||||
selectedCalendar.value = calendar
|
||||
showImportEventsForm.value = true
|
||||
}
|
||||
|
||||
function closeImportEventsForm() {
|
||||
selectedCalendar.value = null
|
||||
showImportEventsForm.value = false
|
||||
}
|
||||
|
||||
function fetchUserCalendars() {
|
||||
api.getUserCalendars().then((resp) => {
|
||||
userCalendars.value = resp.data
|
||||
})
|
||||
}
|
||||
|
||||
function refreshCalendarEvents(calendar) {
|
||||
const fullc = calendarRef.value.getApi()
|
||||
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
|
||||
evtSource.refetch()
|
||||
}
|
||||
|
||||
function selectCallback(info) {
|
||||
selectInfo.value = info
|
||||
showEventForm.value = true
|
||||
}
|
||||
|
||||
async function eventClickCallback(info) {
|
||||
const resp = await api.getUserEvent(
|
||||
info.event.extendedProps.calendar.pk,
|
||||
info.event.id
|
||||
)
|
||||
selectedEvent.value = resp.data
|
||||
showEventForm.value = true
|
||||
}
|
||||
|
||||
function updateEventDates(calEvent) {
|
||||
var data = {
|
||||
start: calEvent.start,
|
||||
end: calEvent.end,
|
||||
}
|
||||
const evtCalendar = calEvent.extendedProps.calendar
|
||||
if (calEvent.allDay) {
|
||||
const end = DateTime.fromJSDate(data.start)
|
||||
end.plus({ days: 1 })
|
||||
data.start.setHours(0, 0, 0)
|
||||
data.end = end
|
||||
data.allDay = calEvent.allDay
|
||||
} else if (!calEvent.end) {
|
||||
const end = DateTime.fromJSDate(data.start)
|
||||
end.plus({ hours: 1 })
|
||||
data.end = end
|
||||
}
|
||||
api.patchUserEvent(evtCalendar.pk, calEvent.id, data).then((response) => {
|
||||
busStore.displayNotification({ msg: $gettext('Event updated') })
|
||||
})
|
||||
}
|
||||
function eventDropCallback(info) {
|
||||
updateEventDates(info.event)
|
||||
}
|
||||
function eventResizeCallback(info) {
|
||||
updateEventDates(info.event)
|
||||
}
|
||||
|
||||
function closeEventForm() {
|
||||
showEventForm.value = false
|
||||
selectedEvent.value = null
|
||||
}
|
||||
|
||||
function editCalendar(calendar) {
|
||||
selectedCalendar.value = calendar
|
||||
showCalendarForm.value = true
|
||||
}
|
||||
|
||||
async function deleteCalendar(calendarPk) {
|
||||
const result = await confirm.value.open(
|
||||
$gettext('Warning'),
|
||||
$gettext('Do you really want to delete this calendar?'),
|
||||
{ color: 'warning' }
|
||||
)
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
await api.deleteUserCalendar(calendarPk)
|
||||
const fullc = calendarRef.value.getApi()
|
||||
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
|
||||
evtSource.remove()
|
||||
busStore.displayNotification({ msg: $gettext('Calendar deleted') })
|
||||
}
|
||||
|
||||
const calendarOptions = {
|
||||
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||
},
|
||||
scrollTime: '09:00:00',
|
||||
height: '100%',
|
||||
firstDay: 1,
|
||||
initialView: 'timeGridWeek',
|
||||
locale: current,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
dayMaxEventRows: true,
|
||||
businessHours: {
|
||||
daysOfWeek: [1, 2, 3, 4, 5],
|
||||
startTime: '09:00',
|
||||
endTime: '18:00',
|
||||
},
|
||||
select: selectCallback,
|
||||
eventClick: eventClickCallback,
|
||||
eventDrop: eventDropCallback,
|
||||
eventResize: eventResizeCallback,
|
||||
}
|
||||
|
||||
watch(
|
||||
leftMenuItems,
|
||||
(value) => {
|
||||
layoutStore.setLeftMenuItems(value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(userCalendars, (value) => {
|
||||
const fullc = calendarRef.value.getApi()
|
||||
if (!fullc) {
|
||||
return
|
||||
}
|
||||
for (const calendar of value) {
|
||||
const evtSource = fullc.getEventSourceById(`user-${calendar.pk}`)
|
||||
if (!evtSource) {
|
||||
fullc.addEventSource({
|
||||
id: `user-${calendar.pk}`,
|
||||
events: fetchUserCalendarEvents.bind(this, calendar.pk),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserCalendars()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="text-h5 ml-4">
|
||||
{{ $gettext('Contacts') }}
|
||||
</div>
|
||||
<ContactList @edit-category="openCategoryForm" />
|
||||
<v-dialog v-model="showCategoryForm" persistent max-width="800px">
|
||||
<CategoryForm
|
||||
:category="contactsStore.currentCategory"
|
||||
@close="closeForm"
|
||||
@added="fetchCategories"
|
||||
@updated="fetchCategories"
|
||||
/>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useContactsStore, useLayoutStore } from '@/stores'
|
||||
import contactsApi from '@/api/contacts'
|
||||
import ContactList from '@/components/contacts/ContactList.vue'
|
||||
import CategoryForm from '@/components/contacts/CategoryForm.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { $gettext } = useGettext()
|
||||
const contactsStore = useContactsStore()
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
const categories = ref([])
|
||||
const showCategoryForm = ref(false)
|
||||
|
||||
const leftMenuItems = computed(() => {
|
||||
const result = [
|
||||
{
|
||||
icon: 'mdi-contacts',
|
||||
text: $gettext('Contacts'),
|
||||
to: { name: 'ContactList' },
|
||||
},
|
||||
{
|
||||
text: $gettext('Categories'),
|
||||
subheader: true,
|
||||
},
|
||||
]
|
||||
for (const category of categories.value) {
|
||||
result.push({
|
||||
icon: 'mdi-label',
|
||||
text: category.name,
|
||||
action: () => filterByCategory(category),
|
||||
})
|
||||
}
|
||||
result.push({
|
||||
icon: 'mdi-plus',
|
||||
text: $gettext('Add a category'),
|
||||
action: openCategoryForm,
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
function openCategoryForm() {
|
||||
showCategoryForm.value = true
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showCategoryForm.value = false
|
||||
}
|
||||
|
||||
function filterByCategory(category) {
|
||||
contactsStore.setCurrentCategory(category)
|
||||
router.push({
|
||||
name: 'ContactList',
|
||||
params: {
|
||||
category: category.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function fetchCategories() {
|
||||
contactsApi.getCategories().then((resp) => {
|
||||
categories.value = resp.data
|
||||
})
|
||||
}
|
||||
|
||||
fetchCategories()
|
||||
|
||||
watch(leftMenuItems, (value) => {
|
||||
layoutStore.setLeftMenuItems(value)
|
||||
})
|
||||
</script>
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="js">
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
const props = defineProps({
|
||||
redirectUrl: String,
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
authStore.completeLogin()
|
||||
authStore.completeLogin(props.redirectUrl)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
https: true,
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['X-Requested-With', 'content-type', 'Authorization'],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: true,
|
||||
|
||||
+512
-542
File diff suppressed because it is too large
Load Diff
+47
-6
@@ -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=ldap --output-file=ldap-requirements.txt pyproject.toml
|
||||
@@ -11,13 +11,17 @@ asgiref==3.8.1
|
||||
# django
|
||||
# django-cors-headers
|
||||
# modoboa (pyproject.toml)
|
||||
async-timeout==5.0.1
|
||||
# via redis
|
||||
attrs==25.1.0
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
bcrypt==4.2.1
|
||||
# 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
|
||||
@@ -54,6 +58,7 @@ django==4.2.18
|
||||
# django-rq
|
||||
# django-xforwardedfor-middleware
|
||||
# djangorestframework
|
||||
# drf-nested-routers
|
||||
# drf-spectacular
|
||||
# modoboa (pyproject.toml)
|
||||
django-auth-ldap==4.8.0
|
||||
@@ -84,10 +89,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
|
||||
@@ -100,6 +108,11 @@ gevent==24.11.1
|
||||
# via modoboa (pyproject.toml)
|
||||
greenlet==3.1.1
|
||||
# via gevent
|
||||
icalendar==6.1.1
|
||||
# via
|
||||
# caldav
|
||||
# recurring-ical-events
|
||||
# x-wr-timezone
|
||||
idna==3.10
|
||||
# via
|
||||
# requests
|
||||
@@ -113,7 +126,9 @@ jsonschema-specifications==2024.10.1
|
||||
jwcrypto==1.5.6
|
||||
# via django-oauth-toolkit
|
||||
lxml==5.3.1
|
||||
# via lxml-html-clean
|
||||
# via
|
||||
# caldav
|
||||
# lxml-html-clean
|
||||
lxml-html-clean==0.4.1
|
||||
# via modoboa (pyproject.toml)
|
||||
oath==1.4.4
|
||||
@@ -143,17 +158,26 @@ pyasn1-modules==0.4.1
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
python-dateutil==2.9.0.post0
|
||||
# via modoboa (pyproject.toml)
|
||||
# via
|
||||
# icalendar
|
||||
# modoboa (pyproject.toml)
|
||||
# recurring-ical-events
|
||||
# vobject
|
||||
python-ldap==3.4.4
|
||||
# via django-auth-ldap
|
||||
python-magic==0.4.27
|
||||
# via modoboa (pyproject.toml)
|
||||
pytz==2025.1
|
||||
# via modoboa (pyproject.toml)
|
||||
# via
|
||||
# caldav
|
||||
# modoboa (pyproject.toml)
|
||||
# vobject
|
||||
pyyaml==6.0.2
|
||||
# via drf-spectacular
|
||||
qrcode==8.0
|
||||
# via modoboa (pyproject.toml)
|
||||
recurring-ical-events==3.4.1
|
||||
# via caldav
|
||||
redis==5.2.1
|
||||
# via
|
||||
# django-rq
|
||||
@@ -167,6 +191,7 @@ reportlab==4.3.1
|
||||
# via modoboa (pyproject.toml)
|
||||
requests==2.32.3
|
||||
# via
|
||||
# caldav
|
||||
# django-oauth-toolkit
|
||||
# modoboa (pyproject.toml)
|
||||
# ovh
|
||||
@@ -192,7 +217,9 @@ sgmllib3k==1.0.0
|
||||
sievelib==1.4.2
|
||||
# via modoboa (pyproject.toml)
|
||||
six==1.17.0
|
||||
# via python-dateutil
|
||||
# via
|
||||
# python-dateutil
|
||||
# vobject
|
||||
sqlparse==0.5.3
|
||||
# via django
|
||||
tldextract==5.1.3
|
||||
@@ -201,13 +228,27 @@ typing-extensions==4.12.2
|
||||
# via
|
||||
# dj-database-url
|
||||
# jwcrypto
|
||||
# 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
|
||||
# modoboa (pyproject.toml)
|
||||
urllib3==2.3.0
|
||||
# via requests
|
||||
vobject==0.9.9
|
||||
# via
|
||||
# caldav
|
||||
# modoboa (pyproject.toml)
|
||||
x-wr-timezone==2.0.0
|
||||
# via recurring-ical-events
|
||||
zope-event==5.0
|
||||
# via gevent
|
||||
zope-interface==7.2
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Admin API v2 serializers."""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
@@ -11,6 +10,7 @@ from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from django.contrib.auth import password_validation
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -353,6 +353,24 @@ class AccountResourceSerializer(serializers.ModelSerializer):
|
||||
model = limits_models.UserObjectLimit
|
||||
|
||||
|
||||
class AccountMeSerializer(v1_serializers.AccountSerializer):
|
||||
"""Custom Account serializer for connected user."""
|
||||
|
||||
permissions = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(v1_serializers.AccountSerializer.Meta):
|
||||
fields = v1_serializers.AccountSerializer.Meta.fields + ("permissions",)
|
||||
|
||||
def get_permissions(self, user) -> List[str]:
|
||||
"""Return available permissions for given user."""
|
||||
permissions = (
|
||||
user.user_permissions.all() | Permission.objects.filter(group__user=user)
|
||||
).select_related("content_type")
|
||||
return [
|
||||
"{}.{}".format(p.content_type.app_label, p.codename) for p in permissions
|
||||
]
|
||||
|
||||
|
||||
class AccountSerializer(v1_serializers.AccountSerializer):
|
||||
"""Add support for user resources."""
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
default_app_config = "modoboa.calendars.apps.CalendarsConfig"
|
||||
@@ -0,0 +1,92 @@
|
||||
import collections
|
||||
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
|
||||
GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"server",
|
||||
{
|
||||
"label": gettext_lazy("Server settings"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"server_location",
|
||||
{
|
||||
"label": gettext_lazy("Server URL"),
|
||||
"help_text": gettext_lazy(
|
||||
"The URL of your Radicale server. "
|
||||
"It will be used to construct calendar URLs."
|
||||
),
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"rights",
|
||||
{
|
||||
"label": gettext_lazy("Rights management"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"rights_file_path",
|
||||
{
|
||||
"label": gettext_lazy("Rights file's path"),
|
||||
"help_text": gettext_lazy(
|
||||
"Path to the file that contains rights definition"
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"allow_calendars_administration",
|
||||
{
|
||||
"label": gettext_lazy("Allow calendars administration"),
|
||||
"help_text": gettext_lazy(
|
||||
"Allow domain administrators to manage user calendars "
|
||||
"(read and write)"
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"misc",
|
||||
{
|
||||
"label": gettext_lazy("Miscellaneous"),
|
||||
"params": collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
"max_ics_file_size",
|
||||
{
|
||||
"label": gettext_lazy("Maximum size of ICS files"),
|
||||
"help_text": gettext_lazy(
|
||||
"Maximum size in bytes of imported ICS files "
|
||||
"(or KB, MB, GB if specified)"
|
||||
),
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def load_settings():
|
||||
"""Load calendars settings."""
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from modoboa.calendars import serializers
|
||||
|
||||
param_tools.registry.add2(
|
||||
"global",
|
||||
"calendars",
|
||||
gettext_lazy("Calendars"),
|
||||
GLOBAL_PARAMETERS_STRUCT,
|
||||
serializers.GlobalParametersSerializer,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""AppConfig for radicale."""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CalendarsConfig(AppConfig):
|
||||
"""App configuration."""
|
||||
|
||||
name = "modoboa.calendars"
|
||||
verbose_name = "Modoboa calendars"
|
||||
|
||||
def ready(self):
|
||||
from modoboa.calendars import handlers
|
||||
from modoboa.calendars.app_settings import load_settings
|
||||
|
||||
load_settings()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Calendar backend definition."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from modoboa.lib.cryptutils import decrypt
|
||||
|
||||
|
||||
class CalendarBackend(object):
|
||||
"""Base backend class."""
|
||||
|
||||
def __init__(self, calendar=None):
|
||||
"""Default constructor."""
|
||||
self.calendar = calendar
|
||||
|
||||
def create_event(self, event):
|
||||
"""Create a new event."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_event(self, uid):
|
||||
"""Retrieve an even using its uid."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_events(self, start, end):
|
||||
"""Retrieve a list of event."""
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_event(self, uid):
|
||||
"""Delete an event using its uid."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def get_backend(name, *args, **kwargs):
|
||||
"""Return a backend instance."""
|
||||
module = import_module("modoboa.calendars.backends.{}".format(name))
|
||||
return getattr(module, "{}Backend".format(name.capitalize()))(*args, **kwargs)
|
||||
|
||||
|
||||
def get_backend_from_request(name, request, calendar=None):
|
||||
"""Return a backend instance from a request."""
|
||||
# password = decrypt(request.session["password"])
|
||||
return get_backend(name, request.user.email, request.auth, calendar=calendar)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""CalDAV calendar backend."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
import caldav
|
||||
from caldav.elements import dav, ical
|
||||
from caldav import Calendar
|
||||
import vobject
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.encoding import smart_str
|
||||
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from . import CalendarBackend
|
||||
|
||||
|
||||
class Caldav_Backend(CalendarBackend):
|
||||
"""CalDAV calendar backend."""
|
||||
|
||||
def __init__(self, username, password, calendar=None):
|
||||
"""Constructor."""
|
||||
super().__init__(calendar)
|
||||
server_url = smart_str(param_tools.get_global_parameter("server_location"))
|
||||
self.client = caldav.DAVClient(server_url, username=username, password=password)
|
||||
if self.calendar:
|
||||
self.remote_cal = Calendar(self.client, calendar.encoded_path)
|
||||
|
||||
def _serialize_event(self, event):
|
||||
"""Convert a vevent to a dictionary."""
|
||||
vevent = event.vobject_instance.vevent
|
||||
description = (
|
||||
vevent.description.value if "description" in vevent.contents else ""
|
||||
)
|
||||
result = {
|
||||
"id": vevent.uid.value,
|
||||
"title": vevent.summary.value,
|
||||
"color": self.calendar.color,
|
||||
"description": description,
|
||||
"calendar": self.calendar,
|
||||
"attendees": [],
|
||||
}
|
||||
if isinstance(vevent.dtstart.value, datetime.datetime):
|
||||
all_day = False
|
||||
start = vevent.dtstart.value
|
||||
end = vevent.dtend.value
|
||||
else:
|
||||
tz = timezone.get_current_timezone()
|
||||
all_day = True
|
||||
start = datetime.datetime.combine(
|
||||
vevent.dtstart.value, datetime.time.min
|
||||
).replace(tzinfo=tz)
|
||||
end = datetime.datetime.combine(
|
||||
vevent.dtend.value, datetime.time.min
|
||||
).replace(tzinfo=tz)
|
||||
result.update({"allDay": all_day, "start": start, "end": end})
|
||||
if "attendee" in vevent.contents:
|
||||
for attendee in vevent.contents["attendee"]:
|
||||
email = attendee.value.replace("mailto:", "").replace("MAILTO:", "")
|
||||
cn = attendee.params.get("CN")
|
||||
result["attendees"].append(
|
||||
{"display_name": cn[0] if cn else "", "email": email}
|
||||
)
|
||||
return result
|
||||
|
||||
def create_calendar(self, url):
|
||||
"""Create a new calendar."""
|
||||
self.client.mkcalendar(url)
|
||||
|
||||
def update_calendar(self, calendar):
|
||||
"""Update an existing calendar."""
|
||||
remote_cal = Calendar(self.client, calendar.encoded_path)
|
||||
remote_cal.set_properties(
|
||||
[dav.DisplayName(calendar.name), ical.CalendarColor(calendar.color)]
|
||||
)
|
||||
|
||||
def create_event(self, data):
|
||||
"""Create a new event."""
|
||||
uid = uuid.uuid4()
|
||||
cal = vobject.iCalendar()
|
||||
cal.add("vevent")
|
||||
cal.vevent.add("uid").value = str(uid)
|
||||
cal.vevent.add("summary").value = data["title"]
|
||||
if not data["allDay"]:
|
||||
cal.vevent.add("dtstart").value = data["start"]
|
||||
cal.vevent.add("dtend").value = data["end"]
|
||||
else:
|
||||
cal.vevent.add("dtstart").value = data["start"].date()
|
||||
cal.vevent.add("dtend").value = data["end"].date()
|
||||
self.remote_cal.add_event(cal)
|
||||
return uid
|
||||
|
||||
def update_event(self, uid, original_data):
|
||||
"""Update an existing event."""
|
||||
data = dict(original_data)
|
||||
url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid)
|
||||
cal = self.remote_cal.event_by_url(url)
|
||||
orig_evt = cal.vobject_instance.vevent
|
||||
if "title" in data:
|
||||
orig_evt.summary.value = data["title"]
|
||||
if data.get("allDay"):
|
||||
data["start"] = data["start"].date()
|
||||
data["end"] = data["end"].date()
|
||||
if "start" in data:
|
||||
del orig_evt.contents["dtstart"]
|
||||
orig_evt.add("dtstart").value = data["start"]
|
||||
if "end" in data:
|
||||
del orig_evt.contents["dtend"]
|
||||
orig_evt.add("dtend").value = data["end"]
|
||||
if "description" in data:
|
||||
if "description" in orig_evt.contents:
|
||||
orig_evt.description.value = data["description"]
|
||||
else:
|
||||
orig_evt.add("description").value = data["description"]
|
||||
if "attendees" in data:
|
||||
if "attendee" in orig_evt.contents:
|
||||
del orig_evt.contents["attendee"]
|
||||
for attdef in data.get("attendees", []):
|
||||
attendee = orig_evt.add("attendee")
|
||||
attendee.value = "MAILTO:{}".format(attdef["email"])
|
||||
attendee.params["CN"] = [attdef["display_name"]]
|
||||
attendee.params["ROLE"] = ["REQ-PARTICIPANT"]
|
||||
if "calendar" in data and self.calendar.pk != data["calendar"].pk:
|
||||
# Calendar has been changed, remove old event first.
|
||||
self.remote_cal.client.delete(url)
|
||||
remote_cal = Calendar(self.client, data["calendar"].encoded_path)
|
||||
url = "{}/{}.ics".format(remote_cal.url.geturl(), uid)
|
||||
else:
|
||||
remote_cal = self.remote_cal
|
||||
remote_cal.add_event(cal.instance)
|
||||
return uid
|
||||
|
||||
def get_event(self, uid):
|
||||
"""Retrieve and event using its uid."""
|
||||
url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid)
|
||||
event = self.remote_cal.event_by_url(url)
|
||||
return self._serialize_event(event)
|
||||
|
||||
def get_events(self, start, end):
|
||||
"""Retrieve a list of events."""
|
||||
orig_events = self.remote_cal.date_search(start, end)
|
||||
events = []
|
||||
for event in orig_events:
|
||||
events.append(self._serialize_event(event))
|
||||
return events
|
||||
|
||||
def delete_event(self, uid):
|
||||
"""Delete an event using its uid."""
|
||||
url = "{}/{}.ics".format(self.remote_cal.url.geturl(), uid)
|
||||
self.remote_cal.client.delete(url)
|
||||
|
||||
def import_events(self, fp):
|
||||
"""Import events from file."""
|
||||
content = smart_str(fp.read())
|
||||
counter = 0
|
||||
for cal in vobject.base.readComponents(content):
|
||||
for event in cal.vevent_list:
|
||||
ical = vobject.iCalendar()
|
||||
ical.add(event)
|
||||
self.remote_cal.add_event(ical.serialize())
|
||||
counter += 1
|
||||
return counter
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Fixtures factories."""
|
||||
|
||||
import factory
|
||||
|
||||
from modoboa.admin import factories as admin_factories
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class UserCalendarFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.UserCalendar
|
||||
|
||||
name = factory.Sequence(lambda n: "User calendar %s" % n)
|
||||
mailbox = factory.SubFactory(admin_factories.MailboxFactory)
|
||||
|
||||
|
||||
class SharedCalendarFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.SharedCalendar
|
||||
|
||||
name = factory.Sequence(lambda n: "Shared calendar %s" % n)
|
||||
domain = factory.SubFactory(admin_factories.DomainFactory)
|
||||
|
||||
|
||||
class AccessRuleFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.AccessRule
|
||||
|
||||
mailbox = factory.SubFactory(admin_factories.MailboxFactory)
|
||||
calendar = factory.SubFactory(UserCalendarFactory)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Radicale signals handlers."""
|
||||
|
||||
try:
|
||||
from secrets import token_hex
|
||||
except ImportError:
|
||||
from os import urandom
|
||||
|
||||
def token_hex(nbytes=None):
|
||||
return urandom(nbytes).hex()
|
||||
|
||||
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
from modoboa.core import signals as core_signals
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
PERMISSIONS = [
|
||||
("calendars", "usercalendar", "add_usercalendar"),
|
||||
("calendars", "usercalendar", "change_usercalendar"),
|
||||
("calendars", "usercalendar", "delete_usercalendar"),
|
||||
("calendars", "sharedcalendar", "add_sharedcalendar"),
|
||||
("calendars", "sharedcalendar", "change_sharedcalendar"),
|
||||
("calendars", "sharedcalendar", "delete_sharedcalendar"),
|
||||
]
|
||||
|
||||
ROLES_PERMISSIONS = {
|
||||
"DomainAdmins": PERMISSIONS,
|
||||
"Resellers": PERMISSIONS,
|
||||
"SimpleUsers": [
|
||||
("calendars", "usercalendar", "add_usercalendar"),
|
||||
("calendars", "usercalendar", "change_usercalendar"),
|
||||
("calendars", "usercalendar", "delete_usercalendar"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@receiver(core_signals.extra_role_permissions)
|
||||
def extra_permissions(sender, role, **kwargs):
|
||||
"""Extra permissions."""
|
||||
return ROLES_PERMISSIONS.get(role, [])
|
||||
|
||||
|
||||
@receiver(signals.pre_save, sender=models.UserCalendar)
|
||||
def set_user_calendar_path(sender, instance, **kwargs):
|
||||
"""Set path at creation."""
|
||||
if instance.pk:
|
||||
return
|
||||
instance._path = "{}/{}".format(instance.mailbox.full_address, instance.name)
|
||||
if not instance.access_token:
|
||||
instance.access_token = token_hex(16)
|
||||
|
||||
|
||||
@receiver(signals.pre_save, sender=models.SharedCalendar)
|
||||
def set_shared_calendar_path(sender, instance, **kwargs):
|
||||
"""Set path at creation."""
|
||||
if instance.pk:
|
||||
return
|
||||
instance._path = "{}/{}".format(instance.domain.name, instance.name)
|
||||
if not instance.access_token:
|
||||
instance.access_token = token_hex(16)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Management command to handle file generation."""
|
||||
|
||||
import os
|
||||
import datetime
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from modoboa.admin.models import Domain
|
||||
from modoboa.core.models import User
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from ... import models
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Management command to handle file generation."""
|
||||
|
||||
help = "Generate Radicale rights file"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add extra arguments to command line."""
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force generation of rights file",
|
||||
)
|
||||
|
||||
def _generate_acr(self, name, user, collection, perm="rw", comment=None):
|
||||
"""Write a new access control rule to the config file."""
|
||||
if comment is not None:
|
||||
self._cfgfile.write("\n# %s" % comment)
|
||||
self._cfgfile.write(
|
||||
"""
|
||||
[%s]
|
||||
user = %s
|
||||
collection = %s
|
||||
permissions = %s
|
||||
"""
|
||||
% (name, user, collection, perm)
|
||||
)
|
||||
|
||||
def _user_access_rules(self):
|
||||
"""Create user access rules."""
|
||||
for acr in models.AccessRule.objects.select_related().all():
|
||||
section = "%s-to-%s-acr" % (acr.mailbox, acr.calendar)
|
||||
permission = ""
|
||||
if acr.read:
|
||||
permission += "r"
|
||||
if acr.write:
|
||||
permission += "w"
|
||||
self._generate_acr(
|
||||
section,
|
||||
acr.mailbox.full_address,
|
||||
acr.calendar.path,
|
||||
permission,
|
||||
)
|
||||
|
||||
def _token_access_rules(self):
|
||||
"""Create token access rules."""
|
||||
for model in [models.UserCalendar, models.SharedCalendar]:
|
||||
for calendar in model.objects.all():
|
||||
section = "token-{}-access".format(calendar._path)
|
||||
self._generate_acr(
|
||||
section,
|
||||
calendar.access_token,
|
||||
calendar._path,
|
||||
perm="r",
|
||||
comment="Read-only access using a token",
|
||||
)
|
||||
|
||||
def _super_admin_rules(self):
|
||||
"""Generate access rules for super administrators."""
|
||||
for sa in User.objects.filter(is_superuser=True):
|
||||
section = "sa-%s-acr" % sa.username
|
||||
self._generate_acr(section, sa.username, ".*")
|
||||
|
||||
def _domain_admin_rules(self):
|
||||
"""Generate access rules for domain adminstrators."""
|
||||
for da in User.objects.filter(groups__name="DomainAdmins"):
|
||||
for domain in Domain.objects.get_for_admin(da):
|
||||
section = "da-%s-to-%s-acr" % (da.email, domain.name)
|
||||
self._generate_acr(section, da.email, "%s/user/.*" % domain.name)
|
||||
|
||||
def _generate_file(self, target):
|
||||
"""
|
||||
A user must not declare a rule for his direct admin!
|
||||
"""
|
||||
self._cfgfile = open(target, "w")
|
||||
self._cfgfile.write(
|
||||
"""# Rights management file for Radicale
|
||||
# This file was generated by Modoboa on %s
|
||||
# DO NOT EDIT MANUALLY!
|
||||
"""
|
||||
% datetime.datetime.today()
|
||||
)
|
||||
|
||||
allow_calendars_administration = param_tools.get_global_parameter(
|
||||
"allow_calendars_administration", app="calendars"
|
||||
)
|
||||
if allow_calendars_administration:
|
||||
self._super_admin_rules()
|
||||
self._domain_admin_rules()
|
||||
|
||||
# self._generate_acr(
|
||||
# "domain-shared-calendars", r"^(.+)@(.+)$", r"{1}/.+$",
|
||||
# comment="Access rule to domain shared calendars"
|
||||
# )
|
||||
self._generate_acr(
|
||||
"principal",
|
||||
r".+",
|
||||
r"{user}",
|
||||
perm="RW",
|
||||
comment="Allow reading and writing principal collection "
|
||||
"(same as user name)",
|
||||
)
|
||||
self._generate_acr(
|
||||
"calendars",
|
||||
r".+",
|
||||
r"{user}/[^/]+",
|
||||
comment="Allow reading and writing calendars and address books "
|
||||
"that are direct children of the principal collection",
|
||||
)
|
||||
|
||||
self._user_access_rules()
|
||||
self._token_access_rules()
|
||||
self._cfgfile.close()
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Command entry point."""
|
||||
path = param_tools.get_global_parameter("rights_file_path", app="calendars")
|
||||
if not options["force"]:
|
||||
try:
|
||||
mtime = datetime.datetime.fromtimestamp(int(os.path.getmtime(path)))
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
tz = timezone.get_current_timezone()
|
||||
qset = models.AccessRule.objects.filter(
|
||||
last_update__gt=mtime.replace(tzinfo=tz)
|
||||
)
|
||||
if not qset.exists():
|
||||
return
|
||||
self._generate_file(path)
|
||||
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("admin", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AccessRule",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
("read", models.BooleanField(default=False)),
|
||||
("write", models.BooleanField(default=False)),
|
||||
("last_update", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "radicale_accessrule",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SharedCalendar",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=200)),
|
||||
(
|
||||
"domain",
|
||||
models.ForeignKey(to="admin.Domain", on_delete=models.CASCADE),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
"db_table": "radicale_sharedcalendar",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserCalendar",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=200)),
|
||||
(
|
||||
"mailbox",
|
||||
models.ForeignKey(to="admin.Mailbox", on_delete=models.CASCADE),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
"db_table": "radicale_usercalendar",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accessrule",
|
||||
name="calendar",
|
||||
field=models.ForeignKey(
|
||||
related_name="rules",
|
||||
to="calendars.UserCalendar",
|
||||
on_delete=models.CASCADE,
|
||||
),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accessrule",
|
||||
name="mailbox",
|
||||
field=models.ForeignKey(to="admin.Mailbox", on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="accessrule",
|
||||
unique_together=set([("mailbox", "calendar")]),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-08-31 15:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("calendars", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sharedcalendar",
|
||||
name="color",
|
||||
field=models.CharField(default="#3a87ad", max_length=7),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="usercalendar",
|
||||
name="color",
|
||||
field=models.CharField(default="#3a87ad", max_length=7),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.11 on 2018-03-24 17:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_path(apps, schema_editor):
|
||||
"""Set path."""
|
||||
UserCalendar = apps.get_model("calendars", "UserCalendar")
|
||||
for calendar in UserCalendar.objects.select_related("mailbox__domain"):
|
||||
calendar._path = "{}@{}/{}".format(
|
||||
calendar.mailbox.address, calendar.mailbox.domain.name, calendar.name
|
||||
)
|
||||
calendar.save()
|
||||
SharedCalendar = apps.get_model("calendars", "SharedCalendar")
|
||||
for calendar in SharedCalendar.objects.select_related("domain"):
|
||||
calendar._path = "{}/{}".format(calendar.domain.name, calendar.name)
|
||||
calendar.save()
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("calendars", "0002_auto_20170831_1721"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sharedcalendar",
|
||||
name="_path",
|
||||
field=models.TextField(default=""),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="usercalendar",
|
||||
name="_path",
|
||||
field=models.TextField(default=""),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(set_path, backwards),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user