├── .github ├── dependabot.yml └── workflows │ └── plugin.yml ├── .gitignore ├── .tx └── config ├── LICENSE ├── MANIFEST.in ├── README.rst ├── frontend ├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── Makefile ├── README.md ├── babel.config.js ├── locale │ ├── br │ │ └── LC_MESSAGES │ │ │ └── app.po │ ├── en │ │ └── LC_MESSAGES │ │ │ └── app.po │ ├── es │ │ └── LC_MESSAGES │ │ │ └── app.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── app.po │ ├── it │ │ └── LC_MESSAGES │ │ │ └── app.po │ └── pl_PL │ │ └── LC_MESSAGES │ │ └── app.po ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── api.js │ ├── components │ │ ├── AddressBookDetail.vue │ │ ├── CategoryForm.vue │ │ ├── ContactCategoriesForm.vue │ │ ├── ContactDetail.vue │ │ ├── ContactForm.vue │ │ ├── ContactList.vue │ │ ├── EmailField.vue │ │ ├── Modal.vue │ │ ├── PhoneNumberField.vue │ │ └── SearchForm.vue │ ├── main.js │ ├── store │ │ ├── actions.js │ │ ├── index.js │ │ ├── modules │ │ │ ├── categories.js │ │ │ ├── detail.js │ │ │ └── list.js │ │ └── mutation-types.js │ └── translations.json ├── tests │ └── unit │ │ ├── .eslintrc │ │ ├── ContactCategoriesForm.spec.js │ │ ├── EmailField.spec.js │ │ ├── PhoneNumberField.spec.js │ │ └── SearchForm.spec.js ├── vue.config.js └── yarn.lock ├── modoboa_contacts ├── __init__.py ├── apps.py ├── constants.py ├── factories.py ├── forms.py ├── handlers.py ├── importer │ ├── __init__.py │ └── backends │ │ ├── __init__.py │ │ └── outlook.py ├── lib │ ├── __init__.py │ └── carddav.py ├── locale │ ├── br │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── it │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── pl_PL │ │ └── LC_MESSAGES │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── import_contacts.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20180124_2311.py │ ├── 0003_auto_20181005_1415.py │ ├── 0004_auto_20181005_1415.py │ ├── 0005_auto_20181005_1445.py │ ├── 0006_alter_phonenumber_type.py │ ├── 0007_alter_contact_address.py │ └── __init__.py ├── mocks.py ├── models.py ├── modo_extension.py ├── serializers.py ├── settings.py ├── tasks.py ├── templates │ └── modoboa_contacts │ │ └── index.html ├── test_data │ ├── outlook_export.csv │ └── unknown_export.csv ├── tests.py ├── urls.py ├── urls_api.py ├── version.py ├── views.py └── viewsets.py ├── pylintrc ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt └── test_project ├── manage.py └── test_project ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/plugin.yml: -------------------------------------------------------------------------------- 1 | name: Modoboa contacts plugin 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths-ignore: 7 | - 'modoboa_contacts/**.po' 8 | - 'frontend/**' 9 | pull_request: 10 | branches: [ master ] 11 | paths-ignore: 12 | - 'modoboa_contacts/**.po' 13 | - 'frontend/**' 14 | release: 15 | branches: [ master ] 16 | types: [ published ] 17 | 18 | env: 19 | POSTGRES_HOST: localhost 20 | 21 | jobs: 22 | test: 23 | runs-on: ubuntu-latest 24 | services: 25 | postgres: 26 | image: postgres:12 27 | env: 28 | POSTGRES_USER: postgres 29 | POSTGRES_PASSWORD: postgres 30 | POSTGRES_DB: postgres 31 | ports: 32 | # will assign a random free host port 33 | - 5432/tcp 34 | # needed because the postgres container does not provide a healthcheck 35 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 36 | mysql: 37 | image: mariadb:11 38 | env: 39 | MARIADB_ROOT_PASSWORD: root 40 | MARIADB_USER: modoboa 41 | MARIADB_PASSWORD: modoboa 42 | MARIADB_DATABASE: modoboa 43 | ports: 44 | - 3306/tcp 45 | options: --health-cmd="mariadb-admin ping" --health-interval=10s --health-timeout=5s --health-retries=3 46 | 47 | strategy: 48 | matrix: 49 | database: ['postgres', 'mysql'] 50 | python-version: [3.9, '3.10', '3.11', '3.12'] 51 | fail-fast: false 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | - name: Set up Python ${{ matrix.python-version }} 56 | uses: actions/setup-python@v5 57 | with: 58 | python-version: ${{ matrix.python-version }} 59 | - name: Install dependencies 60 | run: | 61 | sudo apt-get update -y && sudo apt-get install -y librrd-dev rrdtool 62 | python -m pip install --upgrade pip 63 | pip install -r requirements.txt 64 | pip install -r test-requirements.txt 65 | cd .. 66 | git clone https://github.com/modoboa/modoboa.git 67 | cd modoboa 68 | pip install -e . 69 | cd ../modoboa-contacts 70 | pip install -e . 71 | - name: Install postgres requirements 72 | if: ${{ matrix.database == 'postgres' }} 73 | run: | 74 | pip install 'psycopg[binary]>=3.1' 75 | pip install coverage 76 | echo "DB=postgres" >> $GITHUB_ENV 77 | - name: Install mysql requirements 78 | if: ${{ matrix.database == 'mysql' }} 79 | run: | 80 | pip install 'mysqlclient<2.1.1' 81 | echo "DB=mysql" >> $GITHUB_ENV 82 | - name: Test with pytest 83 | if: ${{ matrix.python-version != '3.12' || matrix.database != 'postgres' }} 84 | run: | 85 | cd test_project 86 | python3 manage.py test modoboa_contacts 87 | env: 88 | # use localhost for the host here because we are running the job on the VM. 89 | # If we were running the job on in a container this would be postgres 90 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} # get randomly assigned published port 91 | MYSQL_HOST: 127.0.0.1 92 | MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} # get randomly assigned published port 93 | MYSQL_USER: root 94 | 95 | - name: Test with pytest and coverage 96 | if: ${{ matrix.python-version == '3.12' && matrix.database == 'postgres' }} 97 | run: | 98 | cd test_project 99 | coverage run --source ../modoboa_contacts manage.py test modoboa_contacts 100 | coverage xml 101 | coverage report 102 | env: 103 | # use localhost for the host here because we are running the job on the VM. 104 | # If we were running the job on in a container this would be postgres 105 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} # get randomly assigned published port 106 | MYSQL_HOST: 127.0.0.1 107 | MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} # get randomly assigned published port 108 | MYSQL_USER: root 109 | - name: Upload coverage result 110 | if: ${{ matrix.python-version == '3.12' && matrix.database == 'postgres' }} 111 | uses: actions/upload-artifact@v4 112 | with: 113 | name: coverage-results 114 | path: test_project/coverage.xml 115 | 116 | coverage: 117 | needs: test 118 | runs-on: ubuntu-latest 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Download coverage results 122 | uses: actions/download-artifact@v4 123 | with: 124 | name: coverage-results 125 | - name: Upload coverage to Codecov 126 | uses: codecov/codecov-action@v4 127 | with: 128 | token: ${{ secrets.CODECOV_TOKEN }} 129 | files: ./coverage.xml 130 | 131 | release: 132 | if: github.event_name != 'pull_request' 133 | needs: coverage 134 | runs-on: ubuntu-latest 135 | permissions: 136 | id-token: write 137 | environment: 138 | name: pypi 139 | url: https://pypi.org/p/modoboa-contacts 140 | steps: 141 | - uses: actions/checkout@v4 142 | with: 143 | fetch-depth: 0 144 | - name: Set up Python 3.12 145 | uses: actions/setup-python@v5 146 | with: 147 | python-version: '3.12' 148 | - name: Build frontend 149 | shell: bash -l {0} 150 | run: | 151 | cd frontend 152 | nvm install 14 153 | npm install 154 | npm run build 155 | cd .. 156 | - name: Build packages 157 | run: | 158 | sudo apt-get install librrd-dev rrdtool libssl-dev gettext 159 | python -m pip install --upgrade pip build 160 | pip install -r requirements.txt 161 | cd .. 162 | git clone https://github.com/modoboa/modoboa.git 163 | cd modoboa 164 | pip install -e . 165 | cd ../modoboa-contacts/modoboa_contacts 166 | django-admin compilemessages 167 | cd .. 168 | python -m build 169 | - name: Publish to Test PyPI 170 | if: endsWith(github.event.ref, '/master') 171 | uses: pypa/gh-action-pypi-publish@release/v1 172 | with: 173 | repository-url: https://test.pypi.org/legacy/ 174 | skip-existing: true 175 | - name: Publish distribution to PyPI 176 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 177 | uses: pypa/gh-action-pypi-publish@release/v1 178 | with: 179 | skip-existing: true 180 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | ./build/ 12 | develop-eggs/ 13 | /dist/ 14 | downloads/ 15 | eggs/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | .eggs 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | *~ 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | 57 | # JS stuff: 58 | node_modules 59 | /static/dist 60 | /frontend/webpack-stats.json 61 | /frontend/package-lock.json 62 | coverage 63 | /modoboa_contacts/static 64 | 65 | # Dev stuff: 66 | .ropeproject 67 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [modoboa.modoboa-contacts-djangopo] 5 | file_filter = modoboa_contacts/locale//LC_MESSAGES/django.po 6 | source_file = modoboa_contacts/locale/en/LC_MESSAGES/django.po 7 | source_lang = en 8 | type = PO 9 | 10 | [modoboa.modoboa-contacts-app] 11 | file_filter = frontend/locale//LC_MESSAGES/app.po 12 | source_file = frontend/locale/en/LC_MESSAGES/app.po 13 | source_lang = en 14 | type = PO 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Antoine Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE requirements.txt 2 | recursive-include modoboa_contacts *.html *.js *.css *.png *.po *.mo *.js.map *.css.map *.json 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Address book plugin for Modoboa 2 | =============================== 3 | 4 | |gha| |codecov| 5 | 6 | Installation 7 | ------------ 8 | 9 | Install this extension system-wide or inside a virtual environment by 10 | running the following command:: 11 | 12 | $ pip install modoboa-contacts 13 | 14 | Edit the settings.py file of your modoboa instance and apply the following modifications: 15 | 16 | - add ``modoboa_contacts`` inside the ``MODOBOA_APPS`` variable like this:: 17 | 18 | MODOBOA_APPS = ( 19 | 'modoboa', 20 | 'modoboa.core', 21 | 'modoboa.lib', 22 | 'modoboa.admin', 23 | 'modoboa.relaydomains', 24 | 'modoboa.limits', 25 | 'modoboa.parameters', 26 | # Extensions here 27 | # ... 28 | 'modoboa_contacts', 29 | ) 30 | 31 | - Add the following at the end of the file:: 32 | 33 | from modoboa_contacts import settings as modoboa_contacts_settings 34 | modoboa_contacts_settings.apply(globals()) 35 | 36 | Finally, run the following commands to setup the database tables:: 37 | 38 | $ cd 39 | $ python manage.py migrate 40 | $ python manage.py collectstatic 41 | $ python manage.py load_initial_data 42 | 43 | For developers 44 | --------------- 45 | 46 | The frontend part of this plugin is developed with `VueJS 2 `_ and 47 | requires `nodejs `_ and `webpack `_. 48 | 49 | Once nodejs is installed on your system, run the following commands:: 50 | 51 | $ cd frontend 52 | $ npm install 53 | $ npm run serve 54 | 55 | To update dist files (the ones that will be distributed with the plugin), run:: 56 | 57 | $ npm run build 58 | 59 | .. |gha| image:: https://github.com/modoboa/modoboa-contacts/actions/workflows/plugin.yml/badge.svg 60 | :target: https://github.com/modoboa/modoboa-contacts/actions/workflows/plugin.yml 61 | 62 | .. |codecov| image:: https://codecov.io/gh/modoboa/modoboa-contacts/branch/master/graph/badge.svg 63 | :target: https://codecov.io/gh/modoboa/modoboa-contacts 64 | -------------------------------------------------------------------------------- /frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 4 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/standard' 9 | ], 10 | parserOptions: { 11 | parser: 'babel-eslint' 12 | }, 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 16 | 'indent': ['error', 4] 17 | }, 18 | overrides: [ 19 | { 20 | files: [ 21 | '**/__tests__/*.{j,t}s?(x)', 22 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 23 | ], 24 | env: { 25 | mocha: true 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | # On OSX the PATH variable isn't exported unless "SHELL" is also set, see: http://stackoverflow.com/a/25506676 2 | SHELL = /bin/bash 3 | NODE_BINDIR = ./node_modules/.bin 4 | export PATH := $(NODE_BINDIR):$(PATH) 5 | 6 | INPUT_DIR = src 7 | # Where to write the files generated by this makefile. 8 | OUTPUT_DIR = . 9 | 10 | # Available locales for the app. 11 | LOCALES = en fr 12 | 13 | # Name of the generated .po files for each available locale. 14 | LOCALE_FILES ?= $(patsubst %,$(OUTPUT_DIR)/locale/%/LC_MESSAGES/app.po,$(LOCALES)) 15 | 16 | GETTEXT_HTML_SOURCES = $(shell find $(INPUT_DIR) -name '*.vue' -o -name '*.html' 2> /dev/null) 17 | GETTEXT_VUE_SOURCES = $(shell find $(INPUT_DIR) -name '*.vue') 18 | GETTEXT_JS_SOURCES = $(shell find $(INPUT_DIR) -name '*.vue' -o -name '*.js') 19 | 20 | # Makefile Targets 21 | .PHONY: clean makemessages translations 22 | 23 | clean: 24 | rm -f /tmp/template.pot $(INPUT_DIR)/translations.json 25 | 26 | makemessages: /tmp/template.pot 27 | 28 | translations: ./$(INPUT_DIR)/translations.json 29 | 30 | # Create a main .pot template, then generate .po files for each available language. 31 | # Thanx to Systematic: https://github.com/Polyconseil/systematic/blob/866d5a/mk/main.mk#L167-L183 32 | /tmp/template.pot: $(GETTEXT_HTML_SOURCES) 33 | # `dir` is a Makefile built-in expansion function which extracts the directory-part of `$@`. 34 | # `$@` is a Makefile automatic variable: the file name of the target of the rule. 35 | # => `mkdir -p /tmp/` 36 | mkdir -p $(dir $@) 37 | which gettext-extract 38 | # Extract gettext strings from templates files and create a POT dictionary template. 39 | gettext-extract --quiet --output $@ $(GETTEXT_HTML_SOURCES) 40 | gettext-extract --startDelimiter "" --endDelimiter "" --quiet --output $@ $(GETTEXT_VUE_SOURCES) 41 | # Extract gettext strings from JavaScript files. 42 | xgettext --language=JavaScript --keyword=npgettext:1c,2,3 \ 43 | --from-code=utf-8 --join-existing --no-wrap \ 44 | --package-name=$(shell node -e "console.log(require('./package.json').name);") \ 45 | --package-version=$(shell node -e "console.log(require('./package.json').version);") \ 46 | --output $@ $(GETTEXT_JS_SOURCES) 47 | # Generate .po files for each available language. 48 | @for lang in $(LOCALES); do \ 49 | export PO_FILE=$(OUTPUT_DIR)/locale/$$lang/LC_MESSAGES/app.po; \ 50 | echo "msgmerge --update $$PO_FILE $@"; \ 51 | mkdir -p $$(dirname $$PO_FILE); \ 52 | [ -f $$PO_FILE ] && msgmerge --lang=$$lang --update $$PO_FILE $@ || msginit --no-translator --locale=$$lang --input=$@ --output-file=$$PO_FILE; \ 53 | msgattrib --no-wrap --no-obsolete -o $$PO_FILE $$PO_FILE; \ 54 | done; 55 | 56 | $(INPUT_DIR)/translations.json: clean /tmp/template.pot 57 | gettext-compile --output $@ $(LOCALE_FILES) 58 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Run your unit tests 19 | ``` 20 | yarn test:unit 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | yarn lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /frontend/locale/br/LC_MESSAGES/app.po: -------------------------------------------------------------------------------- 1 | # English translations for modoboa-contacts package. 2 | # Copyright (C) 2017 THE modoboa-contacts'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the modoboa-contacts package. 4 | # Automatically generated, 2017. 5 | # 6 | # Translators: 7 | # Irriep Nala Novram , 2019 8 | # 9 | msgid "" 10 | msgstr "" 11 | "Project-Id-Version: modoboa-contacts 1.0.0\n" 12 | "Report-Msgid-Bugs-To: \n" 13 | "POT-Creation-Date: 2018-10-12 11:07+0200\n" 14 | "PO-Revision-Date: 2018-01-30 10:28+0000\n" 15 | "Last-Translator: Irriep Nala Novram , 2019\n" 16 | "Language-Team: Breton (https://www.transifex.com/tonio/teams/13749/br/)\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=UTF-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Language: br\n" 21 | "Plural-Forms: nplurals=5; plural=((n%10 == 1) && (n%100 != 11) && (n%100 !=71) && (n%100 !=91) ? 0 :(n%10 == 2) && (n%100 != 12) && (n%100 !=72) && (n%100 !=92) ? 1 :(n%10 ==3 || n%10==4 || n%10==9) && (n%100 < 10 || n% 100 > 19) && (n%100 < 70 || n%100 > 79) && (n%100 < 90 || n%100 > 99) ? 2 :(n != 0 && n % 1000000 == 0) ? 3 : 4);\n" 22 | 23 | #: src/components/ContactList.vue:8 24 | msgid "Add" 25 | msgstr "Ouzhpennañ" 26 | 27 | #: src/App.vue:13 src/components/CategoryForm.vue:3 28 | msgid "Add category" 29 | msgstr "Ouzhpennañ ur rummad" 30 | 31 | #: src/components/ContactDetail.vue:50 src/components/ContactForm.vue:164 32 | #: src/components/EmailField.vue:38 33 | msgid "Address" 34 | msgstr "Chomlec'h" 35 | 36 | #: src/components/AddressBookDetail.vue:3 37 | msgid "Address book information" 38 | msgstr "Chomlec'h titouroù al levr" 39 | 40 | #: src/components/ContactCategoriesForm.vue:20 41 | msgid "Apply" 42 | msgstr "Arloañ" 43 | 44 | #: src/components/ContactDetail.vue:22 45 | msgid "at" 46 | msgstr "da" 47 | 48 | #: src/components/ContactForm.vue:161 49 | msgid "Birth date" 50 | msgstr "Deiziad ganedigezh" 51 | 52 | #: src/App.vue:7 src/components/ContactCategoriesForm.vue:3 53 | msgid "Categories" 54 | msgstr "Rummadoù" 55 | 56 | #: src/components/ContactForm.vue:170 57 | msgid "City" 58 | msgstr "Kêr" 59 | 60 | #: src/components/AddressBookDetail.vue:13 src/components/CategoryForm.vue:15 61 | #: src/components/ContactCategoriesForm.vue:19 62 | #: src/components/ContactForm.vue:109 63 | msgid "Close" 64 | msgstr "Serriñ" 65 | 66 | #: src/components/ContactForm.vue:155 67 | msgid "Company" 68 | msgstr "Embregerezh" 69 | 70 | #: src/App.vue:5 71 | msgid "Contacts" 72 | msgstr "Darempredoù" 73 | 74 | #: src/components/ContactForm.vue:173 75 | msgid "Country" 76 | msgstr "Bro" 77 | 78 | #: src/components/ContactForm.vue:152 src/components/ContactList.vue:23 79 | msgid "Display name" 80 | msgstr "Diskouez an anv" 81 | 82 | #: src/components/ContactForm.vue:4 83 | msgid "Edit contact" 84 | msgstr "Aozañ an darempred" 85 | 86 | #: src/components/ContactList.vue:24 87 | msgid "Email" 88 | msgstr "Postel" 89 | 90 | #: src/components/ContactForm.vue:146 91 | msgid "First name" 92 | msgstr "Anv bihan" 93 | 94 | #: src/components/ContactForm.vue:149 95 | msgid "Last name" 96 | msgstr "Anv familh" 97 | 98 | #: src/components/ContactForm.vue:107 99 | msgid "More" 100 | msgstr "Muioc'h" 101 | 102 | #: src/components/CategoryForm.vue:35 103 | msgid "Name" 104 | msgstr "Anv" 105 | 106 | #: src/components/ContactForm.vue:6 107 | msgid "New contact" 108 | msgstr "Darempred nevez" 109 | 110 | #: src/components/ContactDetail.vue:64 src/components/ContactForm.vue:179 111 | msgid "Note" 112 | msgstr "Notenn" 113 | 114 | #: src/components/ContactList.vue:25 115 | msgid "Phone" 116 | msgstr "Pellgomz" 117 | 118 | #: src/components/PhoneNumberField.vue:38 119 | msgid "Phone number" 120 | msgstr "Niverenn pellgomz" 121 | 122 | #: src/components/ContactForm.vue:158 123 | msgid "Position" 124 | msgstr "Lec'hiadur" 125 | 126 | #: src/components/CategoryForm.vue:16 127 | msgid "Save" 128 | msgstr "Enrollañ" 129 | 130 | #: src/components/SearchForm.vue:23 131 | msgid "Search" 132 | msgstr "Klask" 133 | 134 | #: src/components/ContactForm.vue:176 135 | msgid "State/Province" 136 | msgstr "Stad/Proviñs" 137 | 138 | #: src/components/ContactDetail.vue:16 139 | msgid "Summary" 140 | msgstr "Berradenn" 141 | 142 | #: src/components/ContactList.vue:16 143 | msgid "Synchronize your address book" 144 | msgstr "Sinkronelaat ho levr chomlec'hioù" 145 | 146 | #: src/components/AddressBookDetail.vue:10 147 | msgid "The credentials are the same than the ones you use to access Modoboa." 148 | msgstr "" 149 | "An titouroù anaout a vez ar memes re hag ar re a implijit da dizhout " 150 | "Modoboa." 151 | 152 | #: src/components/AddressBookDetail.vue:6 153 | msgid "" 154 | "To access this address book from the outside (such as Mozilla Thunderbird or" 155 | " your smartphone), use the following URL:" 156 | msgstr "" 157 | "Evit tizhoù al levr chomlec'hioù-mañ adalek an diavaez (evel da skouer " 158 | "Mozilla Thunderbird war ho poellgomzer), implijit an URL da-heul:" 159 | 160 | #: src/components/ContactForm.vue:167 161 | msgid "Zip Code" 162 | msgstr "Kod Zip" 163 | -------------------------------------------------------------------------------- /frontend/locale/en/LC_MESSAGES/app.po: -------------------------------------------------------------------------------- 1 | # English translations for modoboa-contacts package. 2 | # Copyright (C) 2017 THE modoboa-contacts'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the modoboa-contacts package. 4 | # Automatically generated, 2017. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: modoboa-contacts 1.0.0\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2020-05-06 14:15+0200\n" 11 | "PO-Revision-Date: 2020-05-06 14:17+0200\n" 12 | "Last-Translator: Automatically generated\n" 13 | "Language-Team: none\n" 14 | "Language: en\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: src/components/ContactList.vue:8 21 | msgid "Add" 22 | msgstr "Add" 23 | 24 | #: src/App.vue:17 src/components/CategoryForm.vue:4 25 | msgid "Add category" 26 | msgstr "Add category" 27 | 28 | #: src/components/ContactDetail.vue:50 src/components/ContactForm.vue:164 29 | #: src/components/EmailField.vue:40 30 | msgid "Address" 31 | msgstr "Address" 32 | 33 | #: src/components/AddressBookDetail.vue:3 34 | msgid "Address book information" 35 | msgstr "Address book information" 36 | 37 | #: src/components/ContactCategoriesForm.vue:20 38 | msgid "Apply" 39 | msgstr "Apply" 40 | 41 | #: src/components/ContactDetail.vue:22 42 | msgid "at" 43 | msgstr "at" 44 | 45 | #: src/components/ContactForm.vue:161 46 | msgid "Birth date" 47 | msgstr "Birth date" 48 | 49 | #: src/App.vue:7 src/components/ContactCategoriesForm.vue:3 50 | msgid "Categories" 51 | msgstr "Categories" 52 | 53 | #: src/components/ContactForm.vue:170 54 | msgid "City" 55 | msgstr "City" 56 | 57 | #: src/components/AddressBookDetail.vue:13 src/components/CategoryForm.vue:16 58 | #: src/components/ContactCategoriesForm.vue:19 59 | #: src/components/ContactForm.vue:109 60 | msgid "Close" 61 | msgstr "Close" 62 | 63 | #: src/components/ContactForm.vue:155 64 | msgid "Company" 65 | msgstr "Company" 66 | 67 | #: src/App.vue:5 68 | msgid "Contacts" 69 | msgstr "Contacts" 70 | 71 | #: src/components/ContactForm.vue:173 72 | msgid "Country" 73 | msgstr "Country" 74 | 75 | #: src/App.vue:43 76 | msgid "Delete this category" 77 | msgstr "Delete this category" 78 | 79 | #: src/App.vue:64 80 | msgid "Delete this category?" 81 | msgstr "Delete this category?" 82 | 83 | #: src/components/ContactForm.vue:152 src/components/ContactList.vue:23 84 | msgid "Display name" 85 | msgstr "Display name" 86 | 87 | #: src/components/CategoryForm.vue:3 88 | msgid "Edit category" 89 | msgstr "Edit category" 90 | 91 | #: src/components/ContactForm.vue:4 92 | msgid "Edit contact" 93 | msgstr "Edit contact" 94 | 95 | #: src/components/ContactList.vue:24 96 | msgid "Email" 97 | msgstr "Email" 98 | 99 | #: src/components/ContactForm.vue:146 100 | msgid "First name" 101 | msgstr "First name" 102 | 103 | #: src/components/ContactForm.vue:149 104 | msgid "Last name" 105 | msgstr "Last name" 106 | 107 | #: src/App.vue:46 108 | msgid "Modify this category" 109 | msgstr "Modify this category" 110 | 111 | #: src/components/ContactForm.vue:107 112 | msgid "More" 113 | msgstr "More" 114 | 115 | #: src/components/CategoryForm.vue:42 116 | msgid "Name" 117 | msgstr "Name" 118 | 119 | #: src/components/ContactForm.vue:6 120 | msgid "New contact" 121 | msgstr "New contact" 122 | 123 | #: src/components/ContactDetail.vue:64 src/components/ContactForm.vue:179 124 | msgid "Note" 125 | msgstr "Note" 126 | 127 | #: src/components/ContactList.vue:25 128 | msgid "Phone" 129 | msgstr "Phone" 130 | 131 | #: src/components/PhoneNumberField.vue:38 132 | msgid "Phone number" 133 | msgstr "Phone number" 134 | 135 | #: src/components/ContactForm.vue:158 136 | msgid "Position" 137 | msgstr "Position" 138 | 139 | #: src/components/CategoryForm.vue:17 140 | msgid "Save" 141 | msgstr "Save" 142 | 143 | #: src/components/SearchForm.vue:23 144 | msgid "Search" 145 | msgstr "Search" 146 | 147 | #: src/components/ContactForm.vue:176 148 | msgid "State/Province" 149 | msgstr "State/Province" 150 | 151 | #: src/components/ContactDetail.vue:16 152 | msgid "Summary" 153 | msgstr "Summary" 154 | 155 | #: src/components/ContactList.vue:16 156 | msgid "Synchronize your address book" 157 | msgstr "Synchronize your address book" 158 | 159 | #: src/components/AddressBookDetail.vue:10 160 | msgid "The credentials are the same than the ones you use to access Modoboa." 161 | msgstr "The credentials are the same than the ones you use to access Modoboa." 162 | 163 | #: src/components/AddressBookDetail.vue:6 164 | msgid "To access this address book from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:" 165 | msgstr "To access this address book from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:" 166 | 167 | #: src/components/ContactForm.vue:167 168 | msgid "Zip Code" 169 | msgstr "Zip Code" 170 | -------------------------------------------------------------------------------- /frontend/locale/es/LC_MESSAGES/app.po: -------------------------------------------------------------------------------- 1 | # English translations for modoboa-contacts package. 2 | # Copyright (C) 2017 THE modoboa-contacts'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the modoboa-contacts package. 4 | # Automatically generated, 2017. 5 | # 6 | # Translators: 7 | # Oscar Rosario , 2018 8 | # Luis Fajardo López , 2018 9 | # 10 | msgid "" 11 | msgstr "" 12 | "Project-Id-Version: modoboa-contacts 1.0.0\n" 13 | "Report-Msgid-Bugs-To: \n" 14 | "POT-Creation-Date: 2018-10-12 11:07+0200\n" 15 | "PO-Revision-Date: 2018-01-30 10:28+0000\n" 16 | "Last-Translator: Luis Fajardo López , 2018\n" 17 | "Language-Team: Spanish (https://www.transifex.com/tonio/teams/13749/es/)\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Language: es\n" 22 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 23 | 24 | #: src/components/ContactList.vue:8 25 | msgid "Add" 26 | msgstr "Agregar" 27 | 28 | #: src/App.vue:13 src/components/CategoryForm.vue:3 29 | msgid "Add category" 30 | msgstr "Agregar categoría" 31 | 32 | #: src/components/ContactDetail.vue:50 src/components/ContactForm.vue:164 33 | #: src/components/EmailField.vue:38 34 | msgid "Address" 35 | msgstr "Dirección" 36 | 37 | #: src/components/AddressBookDetail.vue:3 38 | msgid "Address book information" 39 | msgstr "Información del libro de direcciones" 40 | 41 | #: src/components/ContactCategoriesForm.vue:20 42 | msgid "Apply" 43 | msgstr "Aplicar" 44 | 45 | #: src/components/ContactDetail.vue:22 46 | msgid "at" 47 | msgstr "en" 48 | 49 | #: src/components/ContactForm.vue:161 50 | msgid "Birth date" 51 | msgstr "Fecha de nacimiento" 52 | 53 | #: src/App.vue:7 src/components/ContactCategoriesForm.vue:3 54 | msgid "Categories" 55 | msgstr "Categorías" 56 | 57 | #: src/components/ContactForm.vue:170 58 | msgid "City" 59 | msgstr "Ciudad" 60 | 61 | #: src/components/AddressBookDetail.vue:13 src/components/CategoryForm.vue:15 62 | #: src/components/ContactCategoriesForm.vue:19 63 | #: src/components/ContactForm.vue:109 64 | msgid "Close" 65 | msgstr "Cerrar" 66 | 67 | #: src/components/ContactForm.vue:155 68 | msgid "Company" 69 | msgstr "Compañía" 70 | 71 | #: src/App.vue:5 72 | msgid "Contacts" 73 | msgstr "Contactos" 74 | 75 | #: src/components/ContactForm.vue:173 76 | msgid "Country" 77 | msgstr "País" 78 | 79 | #: src/components/ContactForm.vue:152 src/components/ContactList.vue:23 80 | msgid "Display name" 81 | msgstr "Nombre a mostrar" 82 | 83 | #: src/components/ContactForm.vue:4 84 | msgid "Edit contact" 85 | msgstr "Editar contacto" 86 | 87 | #: src/components/ContactList.vue:24 88 | msgid "Email" 89 | msgstr "Correo-e" 90 | 91 | #: src/components/ContactForm.vue:146 92 | msgid "First name" 93 | msgstr "Nombre" 94 | 95 | #: src/components/ContactForm.vue:149 96 | msgid "Last name" 97 | msgstr "Apellidos" 98 | 99 | #: src/components/ContactForm.vue:107 100 | msgid "More" 101 | msgstr "Más" 102 | 103 | #: src/components/CategoryForm.vue:35 104 | msgid "Name" 105 | msgstr "Nombre" 106 | 107 | #: src/components/ContactForm.vue:6 108 | msgid "New contact" 109 | msgstr "Nuevo contacto" 110 | 111 | #: src/components/ContactDetail.vue:64 src/components/ContactForm.vue:179 112 | msgid "Note" 113 | msgstr "Nota" 114 | 115 | #: src/components/ContactList.vue:25 116 | msgid "Phone" 117 | msgstr "Teléfono" 118 | 119 | #: src/components/PhoneNumberField.vue:38 120 | msgid "Phone number" 121 | msgstr "Número de teléfono" 122 | 123 | #: src/components/ContactForm.vue:158 124 | msgid "Position" 125 | msgstr "Puesto" 126 | 127 | #: src/components/CategoryForm.vue:16 128 | msgid "Save" 129 | msgstr "Guardar" 130 | 131 | #: src/components/SearchForm.vue:23 132 | msgid "Search" 133 | msgstr "Buscar" 134 | 135 | #: src/components/ContactForm.vue:176 136 | msgid "State/Province" 137 | msgstr "CC.AA./Provincia" 138 | 139 | #: src/components/ContactDetail.vue:16 140 | msgid "Summary" 141 | msgstr "Sumario" 142 | 143 | #: src/components/ContactList.vue:16 144 | msgid "Synchronize your address book" 145 | msgstr "Sincronice su libro de direcciones" 146 | 147 | #: src/components/AddressBookDetail.vue:10 148 | msgid "The credentials are the same than the ones you use to access Modoboa." 149 | msgstr "" 150 | "Las credenciales para acceder a este libro de direcciones son las mismas que" 151 | " usa para acceder a Modoboa." 152 | 153 | #: src/components/AddressBookDetail.vue:6 154 | msgid "" 155 | "To access this address book from the outside (such as Mozilla Thunderbird or" 156 | " your smartphone), use the following URL:" 157 | msgstr "" 158 | "Para acceder remotamente a este libro de direcciones (como con Mozilla " 159 | "Thunderbird o desde su smartphone), use la siguiente URL:" 160 | 161 | #: src/components/ContactForm.vue:167 162 | msgid "Zip Code" 163 | msgstr "Código postal" 164 | -------------------------------------------------------------------------------- /frontend/locale/fr/LC_MESSAGES/app.po: -------------------------------------------------------------------------------- 1 | # English translations for modoboa-contacts package. 2 | # Copyright (C) 2017 THE modoboa-contacts'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the modoboa-contacts package. 4 | # Automatically generated, 2017. 5 | # 6 | # Translators: 7 | # Antoine Nguyen , 2020 8 | # 9 | msgid "" 10 | msgstr "" 11 | "Project-Id-Version: modoboa-contacts 1.0.0\n" 12 | "Report-Msgid-Bugs-To: \n" 13 | "POT-Creation-Date: 2020-05-06 14:15+0200\n" 14 | "PO-Revision-Date: 2018-01-30 10:28+0000\n" 15 | "Last-Translator: Antoine Nguyen , 2020\n" 16 | "Language-Team: French (https://www.transifex.com/tonio/teams/13749/fr/)\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=UTF-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Language: fr\n" 21 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 22 | 23 | #: src/components/ContactList.vue:8 24 | msgid "Add" 25 | msgstr "Ajouter" 26 | 27 | #: src/App.vue:17 src/components/CategoryForm.vue:4 28 | msgid "Add category" 29 | msgstr "Ajouter une catégorie" 30 | 31 | #: src/components/ContactDetail.vue:50 src/components/ContactForm.vue:164 32 | #: src/components/EmailField.vue:40 33 | msgid "Address" 34 | msgstr "Adresse" 35 | 36 | #: src/components/AddressBookDetail.vue:3 37 | msgid "Address book information" 38 | msgstr "Information sur le carnet d'adresse" 39 | 40 | #: src/components/ContactCategoriesForm.vue:20 41 | msgid "Apply" 42 | msgstr "Appliquer" 43 | 44 | #: src/components/ContactDetail.vue:22 45 | msgid "at" 46 | msgstr "chez" 47 | 48 | #: src/components/ContactForm.vue:161 49 | msgid "Birth date" 50 | msgstr "Date de naissance" 51 | 52 | #: src/App.vue:7 src/components/ContactCategoriesForm.vue:3 53 | msgid "Categories" 54 | msgstr "Catégories" 55 | 56 | #: src/components/ContactForm.vue:170 57 | msgid "City" 58 | msgstr "Ville" 59 | 60 | #: src/components/AddressBookDetail.vue:13 src/components/CategoryForm.vue:16 61 | #: src/components/ContactCategoriesForm.vue:19 62 | #: src/components/ContactForm.vue:109 63 | msgid "Close" 64 | msgstr "Fermer" 65 | 66 | #: src/components/ContactForm.vue:155 67 | msgid "Company" 68 | msgstr "Société" 69 | 70 | #: src/App.vue:5 71 | msgid "Contacts" 72 | msgstr "Contacts" 73 | 74 | #: src/components/ContactForm.vue:173 75 | msgid "Country" 76 | msgstr "Pays" 77 | 78 | #: src/App.vue:43 79 | msgid "Delete this category" 80 | msgstr "Supprimer cette catégorie" 81 | 82 | #: src/App.vue:64 83 | msgid "Delete this category?" 84 | msgstr "Supprimer cette catégorie ?" 85 | 86 | #: src/components/ContactForm.vue:152 src/components/ContactList.vue:23 87 | msgid "Display name" 88 | msgstr "Nom complet" 89 | 90 | #: src/components/CategoryForm.vue:3 91 | msgid "Edit category" 92 | msgstr "Modifier la catégorie" 93 | 94 | #: src/components/ContactForm.vue:4 95 | msgid "Edit contact" 96 | msgstr "Editer le contact" 97 | 98 | #: src/components/ContactList.vue:24 99 | msgid "Email" 100 | msgstr "E-mail" 101 | 102 | #: src/components/ContactForm.vue:146 103 | msgid "First name" 104 | msgstr "Prénom" 105 | 106 | #: src/components/ContactForm.vue:149 107 | msgid "Last name" 108 | msgstr "Nom" 109 | 110 | #: src/App.vue:46 111 | msgid "Modify this category" 112 | msgstr "Modifier cette catégorie" 113 | 114 | #: src/components/ContactForm.vue:107 115 | msgid "More" 116 | msgstr "Plus" 117 | 118 | #: src/components/CategoryForm.vue:42 119 | msgid "Name" 120 | msgstr "Nom" 121 | 122 | #: src/components/ContactForm.vue:6 123 | msgid "New contact" 124 | msgstr "Nouveau contact" 125 | 126 | #: src/components/ContactDetail.vue:64 src/components/ContactForm.vue:179 127 | msgid "Note" 128 | msgstr "Note" 129 | 130 | #: src/components/ContactList.vue:25 131 | msgid "Phone" 132 | msgstr "Téléphone" 133 | 134 | #: src/components/PhoneNumberField.vue:38 135 | msgid "Phone number" 136 | msgstr "Numéro de téléphone" 137 | 138 | #: src/components/ContactForm.vue:158 139 | msgid "Position" 140 | msgstr "Fonction" 141 | 142 | #: src/components/CategoryForm.vue:17 143 | msgid "Save" 144 | msgstr "Enregistrer" 145 | 146 | #: src/components/SearchForm.vue:23 147 | msgid "Search" 148 | msgstr "Chercher" 149 | 150 | #: src/components/ContactForm.vue:176 151 | msgid "State/Province" 152 | msgstr "Etat/Région" 153 | 154 | #: src/components/ContactDetail.vue:16 155 | msgid "Summary" 156 | msgstr "Résumé" 157 | 158 | #: src/components/ContactList.vue:16 159 | msgid "Synchronize your address book" 160 | msgstr "Synchroniser votre carnet d'adresse" 161 | 162 | #: src/components/AddressBookDetail.vue:10 163 | msgid "The credentials are the same than the ones you use to access Modoboa." 164 | msgstr "" 165 | "Les identifiants sont les mêmes que ceux utilisés pour accéder à Modoboa." 166 | 167 | #: src/components/AddressBookDetail.vue:6 168 | msgid "" 169 | "To access this address book from the outside (such as Mozilla Thunderbird or" 170 | " your smartphone), use the following URL:" 171 | msgstr "" 172 | "Pour accéder à ce carnet d'adresse depuis l'extérieur (comme Mozilla " 173 | "Thunderbird ou votre smartphone), utilisez l'URL suivante :" 174 | 175 | #: src/components/ContactForm.vue:167 176 | msgid "Zip Code" 177 | msgstr "Code postal" 178 | -------------------------------------------------------------------------------- /frontend/locale/it/LC_MESSAGES/app.po: -------------------------------------------------------------------------------- 1 | # English translations for modoboa-contacts package. 2 | # Copyright (C) 2017 THE modoboa-contacts'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the modoboa-contacts package. 4 | # Automatically generated, 2017. 5 | # 6 | # Translators: 7 | # marcotrevisan , 2018 8 | # 9 | msgid "" 10 | msgstr "" 11 | "Project-Id-Version: modoboa-contacts 1.0.0\n" 12 | "Report-Msgid-Bugs-To: \n" 13 | "POT-Creation-Date: 2018-10-12 11:07+0200\n" 14 | "PO-Revision-Date: 2018-01-30 10:28+0000\n" 15 | "Last-Translator: marcotrevisan , 2018\n" 16 | "Language-Team: Italian (https://www.transifex.com/tonio/teams/13749/it/)\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=UTF-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Language: it\n" 21 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 22 | 23 | #: src/components/ContactList.vue:8 24 | msgid "Add" 25 | msgstr "Aggiungi" 26 | 27 | #: src/App.vue:13 src/components/CategoryForm.vue:3 28 | msgid "Add category" 29 | msgstr "Aggiungi categoria" 30 | 31 | #: src/components/ContactDetail.vue:50 src/components/ContactForm.vue:164 32 | #: src/components/EmailField.vue:38 33 | msgid "Address" 34 | msgstr "Indirizzi" 35 | 36 | #: src/components/AddressBookDetail.vue:3 37 | msgid "Address book information" 38 | msgstr "Informazioni di rubrica" 39 | 40 | #: src/components/ContactCategoriesForm.vue:20 41 | msgid "Apply" 42 | msgstr "Applica" 43 | 44 | #: src/components/ContactDetail.vue:22 45 | msgid "at" 46 | msgstr "" 47 | 48 | #: src/components/ContactForm.vue:161 49 | msgid "Birth date" 50 | msgstr "Data di nascita" 51 | 52 | #: src/App.vue:7 src/components/ContactCategoriesForm.vue:3 53 | msgid "Categories" 54 | msgstr "Categorie" 55 | 56 | #: src/components/ContactForm.vue:170 57 | msgid "City" 58 | msgstr "Città" 59 | 60 | #: src/components/AddressBookDetail.vue:13 src/components/CategoryForm.vue:15 61 | #: src/components/ContactCategoriesForm.vue:19 62 | #: src/components/ContactForm.vue:109 63 | msgid "Close" 64 | msgstr "Chiudi" 65 | 66 | #: src/components/ContactForm.vue:155 67 | msgid "Company" 68 | msgstr "Azienda" 69 | 70 | #: src/App.vue:5 71 | msgid "Contacts" 72 | msgstr "Contatti" 73 | 74 | #: src/components/ContactForm.vue:173 75 | msgid "Country" 76 | msgstr "Paese" 77 | 78 | #: src/components/ContactForm.vue:152 src/components/ContactList.vue:23 79 | msgid "Display name" 80 | msgstr "Nome visualizzato" 81 | 82 | #: src/components/ContactForm.vue:4 83 | msgid "Edit contact" 84 | msgstr "Modifica contatto" 85 | 86 | #: src/components/ContactList.vue:24 87 | msgid "Email" 88 | msgstr "Email" 89 | 90 | #: src/components/ContactForm.vue:146 91 | msgid "First name" 92 | msgstr "Nome" 93 | 94 | #: src/components/ContactForm.vue:149 95 | msgid "Last name" 96 | msgstr "Cognome" 97 | 98 | #: src/components/ContactForm.vue:107 99 | msgid "More" 100 | msgstr "Più informazioni" 101 | 102 | #: src/components/CategoryForm.vue:35 103 | msgid "Name" 104 | msgstr "Nome" 105 | 106 | #: src/components/ContactForm.vue:6 107 | msgid "New contact" 108 | msgstr "Nuovo contatto" 109 | 110 | #: src/components/ContactDetail.vue:64 src/components/ContactForm.vue:179 111 | msgid "Note" 112 | msgstr "Note" 113 | 114 | #: src/components/ContactList.vue:25 115 | msgid "Phone" 116 | msgstr "Telefono" 117 | 118 | #: src/components/PhoneNumberField.vue:38 119 | msgid "Phone number" 120 | msgstr "Numero di telefono" 121 | 122 | #: src/components/ContactForm.vue:158 123 | msgid "Position" 124 | msgstr "Posizione" 125 | 126 | #: src/components/CategoryForm.vue:16 127 | msgid "Save" 128 | msgstr "Salva" 129 | 130 | #: src/components/SearchForm.vue:23 131 | msgid "Search" 132 | msgstr "Cerca" 133 | 134 | #: src/components/ContactForm.vue:176 135 | msgid "State/Province" 136 | msgstr "Provincia" 137 | 138 | #: src/components/ContactDetail.vue:16 139 | msgid "Summary" 140 | msgstr "Sommario" 141 | 142 | #: src/components/ContactList.vue:16 143 | msgid "Synchronize your address book" 144 | msgstr "Sincronizza la rubrica" 145 | 146 | #: src/components/AddressBookDetail.vue:10 147 | msgid "The credentials are the same than the ones you use to access Modoboa." 148 | msgstr "Le credenziali sono le stesse con cui accedi a Modoboa." 149 | 150 | #: src/components/AddressBookDetail.vue:6 151 | msgid "" 152 | "To access this address book from the outside (such as Mozilla Thunderbird or" 153 | " your smartphone), use the following URL:" 154 | msgstr "" 155 | "Per accedere a questa rubrica da applicazioni esterne (es. Mozilla " 156 | "Thunderbird o il tuo smartphone), usa la seguente URL:" 157 | 158 | #: src/components/ContactForm.vue:167 159 | msgid "Zip Code" 160 | msgstr "CAP" 161 | -------------------------------------------------------------------------------- /frontend/locale/pl_PL/LC_MESSAGES/app.po: -------------------------------------------------------------------------------- 1 | # English translations for modoboa-contacts package. 2 | # Copyright (C) 2017 THE modoboa-contacts'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the modoboa-contacts package. 4 | # Automatically generated, 2017. 5 | # 6 | # Translators: 7 | # Wojtek Gajda , 2018 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: modoboa-contacts 1.0.0\n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2018-10-12 11:07+0200\n" 13 | "PO-Revision-Date: 2018-01-30 11:27+0100\n" 14 | "Last-Translator: Wojtek Gajda , 2018\n" 15 | "Language-Team: Polish (Poland) (https://www.transifex.com/tonio/teams/13749/pl_PL/)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Language: pl_PL\n" 20 | "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" 21 | 22 | #: src/components/ContactList.vue:8 23 | msgid "Add" 24 | msgstr "Dodaj" 25 | 26 | #: src/App.vue:13 src/components/CategoryForm.vue:3 27 | msgid "Add category" 28 | msgstr "Dodaj kategorię" 29 | 30 | #: src/components/ContactDetail.vue:50 src/components/ContactForm.vue:164 31 | #: src/components/EmailField.vue:38 32 | msgid "Address" 33 | msgstr "Adres" 34 | 35 | #: src/components/AddressBookDetail.vue:3 36 | msgid "Address book information" 37 | msgstr "" 38 | 39 | #: src/components/ContactCategoriesForm.vue:20 40 | msgid "Apply" 41 | msgstr "" 42 | 43 | #: src/components/ContactDetail.vue:22 44 | msgid "at" 45 | msgstr "w" 46 | 47 | #: src/components/ContactForm.vue:161 48 | msgid "Birth date" 49 | msgstr "Data urodzin" 50 | 51 | #: src/App.vue:7 src/components/ContactCategoriesForm.vue:3 52 | msgid "Categories" 53 | msgstr "Kategorie" 54 | 55 | #: src/components/ContactForm.vue:170 56 | msgid "City" 57 | msgstr "Miejscowość" 58 | 59 | #: src/components/AddressBookDetail.vue:13 src/components/CategoryForm.vue:15 60 | #: src/components/ContactCategoriesForm.vue:19 61 | #: src/components/ContactForm.vue:109 62 | msgid "Close" 63 | msgstr "Zamknij" 64 | 65 | #: src/components/ContactForm.vue:155 66 | msgid "Company" 67 | msgstr "Firma" 68 | 69 | #: src/App.vue:5 70 | msgid "Contacts" 71 | msgstr "Kontakty" 72 | 73 | #: src/components/ContactForm.vue:173 74 | msgid "Country" 75 | msgstr "Kraj" 76 | 77 | #: src/components/ContactForm.vue:152 src/components/ContactList.vue:23 78 | msgid "Display name" 79 | msgstr "Wyświetlana nazwa" 80 | 81 | #: src/components/ContactForm.vue:4 82 | msgid "Edit contact" 83 | msgstr "Edytuj kontakt" 84 | 85 | #: src/components/ContactList.vue:24 86 | msgid "Email" 87 | msgstr "Email" 88 | 89 | #: src/components/ContactForm.vue:146 90 | msgid "First name" 91 | msgstr "Imię" 92 | 93 | #: src/components/ContactForm.vue:149 94 | msgid "Last name" 95 | msgstr "Nazwisko" 96 | 97 | #: src/components/ContactForm.vue:107 98 | msgid "More" 99 | msgstr "Więcej" 100 | 101 | #: src/components/CategoryForm.vue:35 102 | msgid "Name" 103 | msgstr "Nazwa" 104 | 105 | #: src/components/ContactForm.vue:6 106 | msgid "New contact" 107 | msgstr "Nowy kontakt" 108 | 109 | #: src/components/ContactDetail.vue:64 src/components/ContactForm.vue:179 110 | msgid "Note" 111 | msgstr "" 112 | 113 | #: src/components/ContactList.vue:25 114 | msgid "Phone" 115 | msgstr "Telefon" 116 | 117 | #: src/components/PhoneNumberField.vue:38 118 | msgid "Phone number" 119 | msgstr "" 120 | 121 | #: src/components/ContactForm.vue:158 122 | msgid "Position" 123 | msgstr "" 124 | 125 | #: src/components/CategoryForm.vue:16 126 | msgid "Save" 127 | msgstr "" 128 | 129 | #: src/components/SearchForm.vue:23 130 | msgid "Search" 131 | msgstr "Szukaj" 132 | 133 | #: src/components/ContactForm.vue:176 134 | msgid "State/Province" 135 | msgstr "" 136 | 137 | #: src/components/ContactDetail.vue:16 138 | msgid "Summary" 139 | msgstr "Podsumowanie" 140 | 141 | #: src/components/ContactList.vue:16 142 | msgid "Synchronize your address book" 143 | msgstr "" 144 | 145 | #: src/components/AddressBookDetail.vue:10 146 | msgid "The credentials are the same than the ones you use to access Modoboa." 147 | msgstr "" 148 | 149 | #: src/components/AddressBookDetail.vue:6 150 | msgid "" 151 | "To access this address book from the outside (such as Mozilla Thunderbird or" 152 | " your smartphone), use the following URL:" 153 | msgstr "" 154 | 155 | #: src/components/ContactForm.vue:167 156 | msgid "Zip Code" 157 | msgstr "Kod Pocztowy" 158 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "lint": "vue-cli-service lint" 10 | }, 11 | "dependencies": { 12 | "core-js": "^3.6.4", 13 | "electron": "^22.3.25", 14 | "inject-loader": "^4.0.1", 15 | "js-cookie": "^2.2.1", 16 | "moment": "^2.29.4", 17 | "vue": "^2.6.11", 18 | "vue-gettext": "^2.1.8", 19 | "vue-resource": "^1.5.1", 20 | "vue-router": "^3.1.5", 21 | "vuejs-datepicker": "^1.6.2", 22 | "vuex": "^3.1.2" 23 | }, 24 | "devDependencies": { 25 | "@vue/cli-plugin-babel": "~4.2.0", 26 | "@vue/cli-plugin-eslint": "~4.2.0", 27 | "@vue/cli-plugin-router": "~4.2.0", 28 | "@vue/cli-plugin-unit-mocha": "~4.2.0", 29 | "@vue/cli-plugin-vuex": "~4.2.0", 30 | "@vue/cli-service": "~4.2.0", 31 | "@vue/eslint-config-standard": "^5.1.0", 32 | "@vue/test-utils": "1.0.0-beta.31", 33 | "babel-eslint": "^10.0.3", 34 | "chai": "^4.1.2", 35 | "easygettext": "^2.9.0", 36 | "eslint": "^6.7.2", 37 | "eslint-plugin-import": "^2.20.1", 38 | "eslint-plugin-node": "^11.0.0", 39 | "eslint-plugin-promise": "^4.2.1", 40 | "eslint-plugin-standard": "^4.0.0", 41 | "eslint-plugin-vue": "^6.1.2", 42 | "sass": "^1.25.0", 43 | "sass-loader": "^8.0.2", 44 | "sinon": "^8.1.1", 45 | "sinon-chai": "^3.4.0", 46 | "vue-template-compiler": "^2.6.11", 47 | "webpack-bundle-tracker": "^1.8.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-contacts/3620890134b1d6400a09104df83af9832d17fe8b/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 75 | -------------------------------------------------------------------------------- /frontend/src/api.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueResource from 'vue-resource' 3 | 4 | Vue.use(VueResource) 5 | 6 | var customAddressBookActions = { 7 | default: { method: 'GET', url: '/api/v1/address-books/default/' }, 8 | sync: { method: 'GET', url: '/api/v1/address-books/sync_to_cdav/' } 9 | } 10 | var addressBookResource = Vue.resource( 11 | '/api/v1/address-books{/pk}/', {}, customAddressBookActions) 12 | var categoryResource = Vue.resource('/api/v1/categories{/pk}/') 13 | var contactResource = Vue.resource('/api/v1/contacts{/pk}/') 14 | 15 | // address book API 16 | const getDefaultAddressBook = () => { 17 | return addressBookResource.default() 18 | } 19 | 20 | const syncAddressBook = () => { 21 | return addressBookResource.sync() 22 | } 23 | 24 | // categories API 25 | const createCategory = (data) => { 26 | return categoryResource.save(data) 27 | } 28 | 29 | const getCategories = () => { 30 | return categoryResource.get() 31 | } 32 | 33 | const updateCategory = (pk, data) => { 34 | return categoryResource.update({ pk: pk }, data) 35 | } 36 | 37 | const deleteCategory = (pk) => { 38 | return categoryResource.delete({ pk: pk }) 39 | } 40 | 41 | // contacts API 42 | const createContact = (data) => { 43 | return contactResource.save(data) 44 | } 45 | 46 | const deleteContact = (pk) => { 47 | return contactResource.delete({ pk: pk }) 48 | } 49 | 50 | const getContact = (pk) => { 51 | return contactResource.get({ pk: pk }) 52 | } 53 | 54 | const getContacts = (query, category) => { 55 | var params = {} 56 | 57 | if (query !== undefined) { 58 | params.search = query 59 | } 60 | if (category !== undefined) { 61 | params.category = category 62 | } 63 | return contactResource.get(params) 64 | } 65 | 66 | const updateContact = (pk, data) => { 67 | return contactResource.update({ pk: pk }, data) 68 | } 69 | 70 | export { 71 | getDefaultAddressBook, 72 | syncAddressBook, 73 | 74 | createCategory, 75 | getCategories, 76 | updateCategory, 77 | deleteCategory, 78 | 79 | createContact, 80 | deleteContact, 81 | getContact, 82 | getContacts, 83 | updateContact 84 | } 85 | -------------------------------------------------------------------------------- /frontend/src/components/AddressBookDetail.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | -------------------------------------------------------------------------------- /frontend/src/components/CategoryForm.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 65 | -------------------------------------------------------------------------------- /frontend/src/components/ContactCategoriesForm.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 65 | -------------------------------------------------------------------------------- /frontend/src/components/ContactDetail.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 132 | -------------------------------------------------------------------------------- /frontend/src/components/ContactForm.vue: -------------------------------------------------------------------------------- 1 | 118 | 119 | 263 | -------------------------------------------------------------------------------- /frontend/src/components/ContactList.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 129 | -------------------------------------------------------------------------------- /frontend/src/components/EmailField.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 45 | -------------------------------------------------------------------------------- /frontend/src/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 34 | 35 | 41 | -------------------------------------------------------------------------------- /frontend/src/components/PhoneNumberField.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 43 | -------------------------------------------------------------------------------- /frontend/src/components/SearchForm.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 33 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | 4 | import Cookies from 'js-cookie' 5 | import moment from 'moment' 6 | import GetTextPlugin from 'vue-gettext' 7 | 8 | import store from './store' 9 | import App from './App.vue' 10 | import translations from './translations.json' 11 | import ContactDetail from './components/ContactDetail.vue' 12 | import ContactList from './components/ContactList.vue' 13 | import Modal from './components/Modal.vue' 14 | 15 | Vue.use(GetTextPlugin, { 16 | availableLanguages: { 17 | en: 'English', 18 | fr: 'Français' 19 | }, 20 | translations: translations 21 | }) 22 | Vue.use(VueRouter) 23 | 24 | Vue.component('modal', Modal) 25 | 26 | Vue.filter('formatDate', (value) => { 27 | if (value) { 28 | return moment(String(value)).format('MM/DD/YYYY') 29 | } 30 | }) 31 | Vue.filter('translate', value => { 32 | return !value ? '' : Vue.prototype.$gettext(value.toString()) 33 | }) 34 | 35 | const csrftoken = Cookies.get('csrftoken') 36 | Vue.http.headers.common['X-CSRFTOKEN'] = csrftoken 37 | 38 | const routes = [ 39 | { path: '/', name: 'contact-list', component: ContactList }, 40 | { path: '/:pk(\\d+)', name: 'contact-detail', component: ContactDetail }, 41 | { path: '/:category([\\w%]+)', name: 'contact-list-filtered', component: ContactList } 42 | ] 43 | 44 | export var router = new VueRouter({ 45 | routes, 46 | linkActiveClass: 'active' 47 | }) 48 | 49 | Vue.config.productionTip = false 50 | 51 | // eslint-disable-next-line no-new 52 | new Vue({ 53 | render: h => h(App), 54 | router, 55 | store 56 | }).$mount('#app') 57 | 58 | /* global userLang */ 59 | Vue.config.language = userLang 60 | -------------------------------------------------------------------------------- /frontend/src/store/actions.js: -------------------------------------------------------------------------------- 1 | import * as types from './mutation-types' 2 | import * as api from '../api' 3 | 4 | export const deleteContact = ({ commit }, pk) => { 5 | return api.deleteContact(pk).then(response => { 6 | commit(types.DELETE_CONTACT, { pk: pk }) 7 | }) 8 | } 9 | 10 | export const updateContact = ({ commit }, [pk, data]) => { 11 | return api.updateContact(pk, data).then(response => { 12 | commit(types.UPDATE_CONTACT, { contact: response.data }) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | import * as actions from './actions' 5 | import categories from './modules/categories' 6 | import detail from './modules/detail' 7 | import list from './modules/list' 8 | 9 | Vue.use(Vuex) 10 | 11 | const options = { 12 | actions, 13 | modules: { 14 | categories, 15 | detail, 16 | list 17 | }, 18 | strict: process.env.NODE_ENV !== 'production' 19 | } 20 | 21 | export default new Vuex.Store(options) 22 | export { options } 23 | -------------------------------------------------------------------------------- /frontend/src/store/modules/categories.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import * as api from '../../api' 4 | import * as types from '../mutation-types' 5 | 6 | // initial state 7 | const state = { 8 | categories: [] 9 | } 10 | 11 | // getters 12 | const getters = { 13 | categories: state => state.categories 14 | } 15 | 16 | // actions 17 | const actions = { 18 | createCategory ({ commit }, data) { 19 | return api.createCategory(data).then(response => { 20 | commit(types.ADD_CATEGORY, { category: response.data }) 21 | }) 22 | }, 23 | 24 | getCategories ({ commit }) { 25 | return api.getCategories().then(response => { 26 | commit(types.SET_CATEGORIES, { categories: response.data }) 27 | }) 28 | }, 29 | 30 | updateCategory ({ commit }, data) { 31 | return api.updateCategory(data.pk, data).then(response => { 32 | commit(types.UPDATE_CATEGORY, { category: response.data }) 33 | }) 34 | }, 35 | 36 | deleteCategory ({ commit }, data) { 37 | return api.deleteCategory(data.pk).then(response => { 38 | commit(types.DELETE_CATEGORY, { pk: data.pk }) 39 | }) 40 | } 41 | } 42 | 43 | // mutations 44 | const mutations = { 45 | [types.ADD_CATEGORY] (state, { category }) { 46 | state.categories.push(category) 47 | }, 48 | 49 | [types.SET_CATEGORIES] (state, { categories }) { 50 | state.categories = categories 51 | }, 52 | 53 | [types.UPDATE_CATEGORY] (state, { category }) { 54 | state.categories.filter(function (item, pos) { 55 | if (item.pk === category.pk) { 56 | Vue.set(state.categories, pos, category) 57 | } 58 | }) 59 | }, 60 | 61 | [types.DELETE_CATEGORY] (state, { pk }) { 62 | state.categories = state.categories.filter(function (category) { 63 | return category.pk !== pk 64 | }) 65 | } 66 | } 67 | 68 | export default { 69 | state, 70 | getters, 71 | actions, 72 | mutations 73 | } 74 | -------------------------------------------------------------------------------- /frontend/src/store/modules/detail.js: -------------------------------------------------------------------------------- 1 | import * as api from '../../api' 2 | import * as types from '../mutation-types' 3 | 4 | // initial state 5 | const state = { 6 | contact: [] 7 | } 8 | 9 | // getters 10 | const getters = { 11 | contact: state => state.contact 12 | } 13 | 14 | // actions 15 | const actions = { 16 | getContact ({ commit }, pk) { 17 | return api.getContact(pk).then(response => { 18 | commit(types.SET_CONTACT, { contact: response.data }) 19 | }) 20 | } 21 | } 22 | 23 | // mutations 24 | const mutations = { 25 | [types.SET_CONTACT] (state, { contact }) { 26 | state.contact = contact 27 | }, 28 | 29 | [types.DELETE_CONTACT] (state, { contact }) { 30 | state.contact = {} 31 | }, 32 | 33 | [types.UPDATE_CONTACT] (state, { contact }) { 34 | state.contact = contact 35 | } 36 | } 37 | 38 | export default { 39 | state, 40 | getters, 41 | actions, 42 | mutations 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/store/modules/list.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import * as api from '../../api' 4 | import * as types from '../mutation-types' 5 | 6 | // initial state 7 | const state = { 8 | contacts: [] 9 | } 10 | 11 | // getters 12 | const getters = { 13 | contacts: state => state.contacts 14 | } 15 | 16 | // actions 17 | const actions = { 18 | createContact ({ commit }, data) { 19 | return api.createContact(data).then(response => { 20 | commit(types.ADD_CONTACT, { contact: response.data }) 21 | }) 22 | }, 23 | 24 | getContacts ({ commit }, [query, category]) { 25 | return api.getContacts(query, category).then(response => { 26 | commit(types.SET_CONTACTS, { contacts: response.data }) 27 | }) 28 | } 29 | } 30 | 31 | // mutations 32 | const mutations = { 33 | [types.ADD_CONTACT] (state, { contact }) { 34 | state.contacts.push(contact) 35 | }, 36 | 37 | [types.DELETE_CONTACT] (state, { pk }) { 38 | state.contacts = state.contacts.filter(function (contact) { 39 | return contact.pk !== pk 40 | }) 41 | }, 42 | 43 | [types.SET_CONTACTS] (state, { contacts }) { 44 | state.contacts = contacts 45 | }, 46 | 47 | [types.UPDATE_CONTACT] (state, { contact }) { 48 | state.contacts.filter(function (item, pos) { 49 | if (item.pk === contact.pk) { 50 | Vue.set(state.contacts, pos, contact) 51 | } 52 | }) 53 | } 54 | } 55 | 56 | export default { 57 | state, 58 | getters, 59 | actions, 60 | mutations 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | // list module mutations 2 | export const ADD_CONTACT = 'ADD_CONTACT' 3 | export const DELETE_CONTACT = 'DELETE_CONTACT' 4 | export const SET_CONTACTS = 'SET_CONTACTS' 5 | export const UPDATE_CONTACT = 'UPDATE_CONTACT' 6 | 7 | // detail module mutations 8 | export const SET_CONTACT = 'SET_CONTACT' 9 | 10 | // categories module mutations 11 | export const ADD_CATEGORY = 'ADD_CATEGORY' 12 | export const SET_CATEGORIES = 'SET_CATEGORIES' 13 | export const UPDATE_CATEGORY = 'UPDATE_CATEGORY' 14 | export const DELETE_CATEGORY = 'DELETE_CATEGORY' 15 | -------------------------------------------------------------------------------- /frontend/src/translations.json: -------------------------------------------------------------------------------- 1 | {"en":{"Add":"Add","Add category":"Add category","Address":"Address","Address book information":"Address book information","Apply":"Apply","at":"at","Birth date":"Birth date","Categories":"Categories","City":"City","Close":"Close","Company":"Company","Contacts":"Contacts","Country":"Country","Display name":"Display name","Edit contact":"Edit contact","Email":"Email","First name":"First name","Last name":"Last name","More":"More","Name":"Name","New contact":"New contact","Note":"Note","Phone":"Phone","Phone number":"Phone number","Position":"Position","Save":"Save","Search":"Search","State/Province":"State/Province","Summary":"Summary","Synchronize your address book":"Synchronize your address book","The credentials are the same than the ones you use to access Modoboa.":"The credentials are the same than the ones you use to access Modoboa.","To access this address book from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:":"To access this address book from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:","Zip Code":"Zip Code"},"fr":{"Add":"Ajouter","Add category":"Ajouter une catégorie","Address":"Adresse","Address book information":"Information sur le carnet d'adresse","Apply":"Appliquer","at":"chez","Birth date":"Date de naissance","Categories":"Catégories","City":"Ville","Close":"Fermer","Company":"Société","Contacts":"Contacts","Country":"Pays","Display name":"Nom complet","Edit contact":"Editer le contact","Email":"E-mail","First name":"Prénom","Last name":"Nom","More":"Plus","Name":"Nom","New contact":"Nouveau contact","Note":"Note","Phone":"Téléphone","Phone number":"Numéro de téléphone","Position":"Fonction","Save":"Enregistrer","Search":"Chercher","State/Province":"Etat/Région","Summary":"Résumé","Synchronize your address book":"Synchroniser votre carnet d'adresse","The credentials are the same than the ones you use to access Modoboa.":"Les identifiants sont les mêmes que ceux utilisés pour accéder à Modoboa.","To access this address book from the outside (such as Mozilla Thunderbird or your smartphone), use the following URL:":"Pour accéder à ce carnet d'adresse depuis l'extérieur (comme Mozilla Thunderbird ou votre smartphone), utilisez l'URL suivante :","Zip Code":"Code postal"}} -------------------------------------------------------------------------------- /frontend/tests/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true, 7 | "sinon": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/tests/unit/ContactCategoriesForm.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import GetTextPlugin from 'vue-gettext' 4 | import translations from '@/translations.json' 5 | 6 | import { expect } from 'chai' 7 | 8 | import ContactCategoriesForm from '@/components/ContactCategoriesForm.vue' 9 | import categories from '@/store/modules/categories' 10 | import list from '@/store/modules/list' 11 | 12 | describe('ContactCategoriesForm.vue', () => { 13 | let store 14 | 15 | beforeEach(() => { 16 | const actionsInjector = require('inject-loader!@/store/actions') // eslint-disable-line import/no-webpack-loader-syntax 17 | const actions = actionsInjector({ 18 | '../api': { 19 | updateContact (pk, data) { 20 | return Promise.resolve({ 21 | data: { 22 | pk: 1, 23 | first_name: 'Homer', 24 | last_name: 'Simpson', 25 | emails: [{ 26 | address: 'homer@simpson.com', 27 | type: 'home' 28 | }], 29 | categories: [1, 2] 30 | } 31 | }) 32 | } 33 | } 34 | }) 35 | GetTextPlugin.installed = false 36 | Vue.use(GetTextPlugin, { translations: translations }) 37 | Vue.use(Vuex) 38 | store = new Vuex.Store({ 39 | actions, 40 | modules: { 41 | categories: { 42 | state: { 43 | categories: [{ 44 | pk: 1, 45 | name: 'Test 1' 46 | }, { 47 | pk: 2, 48 | name: 'Test 2' 49 | }] 50 | }, 51 | getters: categories.getters 52 | }, 53 | list: { 54 | mutations: list.mutations, 55 | state: { 56 | contacts: [{ 57 | pk: 1, 58 | first_name: 'Homer', 59 | last_name: 'Simpson', 60 | emails: [{ 61 | address: 'homer@simpson.com', 62 | type: 'home' 63 | }], 64 | categories: [1] 65 | }] 66 | } 67 | } 68 | } 69 | }) 70 | }) 71 | 72 | it('should render correct content', () => { 73 | const Ctor = Vue.extend({ ...ContactCategoriesForm, store }) 74 | const vm = new Ctor({ propsData: { index: 0 } }).$mount() 75 | Vue.nextTick().then(() => { 76 | expect(vm.checkedCategories).to.deep.equal([1]) 77 | const input = vm.$el.querySelectorAll('input[type=checkbox]')[0] 78 | expect(input.value).to.equal(1) 79 | }) 80 | }) 81 | 82 | it('should update contact categories', (done) => { 83 | const Ctor = Vue.extend({ ...ContactCategoriesForm, store }) 84 | const vm = new Ctor({ propsData: { index: 0 } }).$mount() 85 | 86 | vm.checkedCategories = [1, 2] 87 | const form = vm.$el.querySelector('#categoriesForm') 88 | form.dispatchEvent(new Event('submit')) 89 | Vue.nextTick().then(() => { 90 | expect(store.state.list.contacts[0].categories).to.deep.equal([1, 2]) 91 | done() 92 | }).catch(done) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /frontend/tests/unit/EmailField.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { expect } from 'chai' 3 | 4 | import Vue from 'vue' 5 | 6 | import EmailField from '@/components/EmailField.vue' 7 | 8 | describe('EmailField.vue', () => { 9 | it('should render correct contents', () => { 10 | const Ctor = Vue.extend(EmailField) 11 | const vm = new Ctor({ propsData: { index: 0, errors: {}, email: {} } }).$mount() 12 | expect(vm.$el).to.be.ok // eslint-disable-line no-unused-expressions 13 | }) 14 | 15 | it('should render correct content with data', () => { 16 | const Ctor = Vue.extend(EmailField) 17 | const vm = new Ctor({ 18 | propsData: { 19 | index: 0, errors: {}, email: { address: 'test@toto.com', type: 'home' } 20 | } 21 | }).$mount() 22 | expect(vm.$el).to.be.ok // eslint-disable-line no-unused-expressions 23 | assert.equal(vm.email.address, 'test@toto.com') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /frontend/tests/unit/PhoneNumberField.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { expect } from 'chai' 3 | 4 | import Vue from 'vue' 5 | 6 | import PhoneNumberField from '@/components/PhoneNumberField.vue' 7 | 8 | describe('PhoneNumberField.vue', () => { 9 | it('should render correct contents', () => { 10 | const Ctor = Vue.extend(PhoneNumberField) 11 | const vm = new Ctor({ propsData: { index: 0, errors: {}, phone: {} } }).$mount() 12 | expect(vm.$el).to.be.ok // eslint-disable-line no-unused-expressions 13 | }) 14 | 15 | it('should render correct content with data', () => { 16 | const Ctor = Vue.extend(PhoneNumberField) 17 | const vm = new Ctor({ 18 | propsData: { 19 | index: 0, errors: {}, phone: { number: '0123456789', type: 'cell' } 20 | } 21 | }).$mount() 22 | expect(vm.$el).to.be.ok // eslint-disable-line no-unused-expressions 23 | assert.equal(vm.phone.number, '0123456789') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /frontend/tests/unit/SearchForm.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import sinonChai from 'sinon-chai' 3 | import sinon from 'sinon' 4 | 5 | import Vue from 'vue' 6 | 7 | import GetTextPlugin from 'vue-gettext' 8 | import translations from '@/translations.json' 9 | 10 | import SearchForm from '@/components/SearchForm.vue' 11 | 12 | describe('SearchForm.vue', () => { 13 | beforeEach(function () { 14 | GetTextPlugin.installed = false 15 | chai.use(sinonChai) 16 | Vue.use(GetTextPlugin, { translations: translations }) 17 | }) 18 | 19 | it('should render correct contents', () => { 20 | const Ctor = Vue.extend(SearchForm) 21 | const vm = new Ctor().$mount() 22 | chai.expect(vm.$el).to.be.ok // eslint-disable-line no-unused-expressions 23 | }) 24 | 25 | it('form submit should emit "search" event with query', () => { 26 | const Ctor = Vue.extend(SearchForm) 27 | const vm = new Ctor().$mount() 28 | const spy = sinon.spy() 29 | 30 | vm.$on('search', spy) 31 | vm.query = 'Test' 32 | vm.$el.dispatchEvent(new Event('submit')) 33 | chai.expect(spy).to.have.been.calledWith('Test') 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var BundleTracker = require('webpack-bundle-tracker'); 3 | 4 | module.exports = { 5 | publicPath: process.env.NODE_ENV === 'production' 6 | ? '/sitestatic/' 7 | : 'http://localhost:8080/', 8 | outputDir: '../modoboa_contacts/static/', 9 | assetsDir: 'modoboa_contacts', 10 | chainWebpack: config => { 11 | config 12 | .plugin('BundleTracker') 13 | .use(BundleTracker, [{ 14 | filename: path.join('../modoboa_contacts/static/modoboa_contacts/', 'webpack-stats.json') 15 | }]) 16 | 17 | config.devServer 18 | .public('http://localhost:8080') 19 | .port(8080) 20 | .hotOnly(true) 21 | .watchOptions({poll: 1000}) 22 | .https(false) 23 | .headers({ 24 | 'Access-Control-Allow-Origin': '*', 25 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 26 | 'Access-Control-Allow-Headers': 27 | 'X-Requested-With, content-type, Authorization', 28 | 'Access-Control-Allow-Credentials': 'true' 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /modoboa_contacts/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Address book for Modoboa.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | from pkg_resources import get_distribution, DistributionNotFound 8 | 9 | 10 | try: 11 | __version__ = get_distribution(__name__).version 12 | except DistributionNotFound: 13 | # package is not installed 14 | pass 15 | 16 | default_app_config = "modoboa_contacts.apps.ModoboaContactsConfig" 17 | -------------------------------------------------------------------------------- /modoboa_contacts/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class ModoboaContactsConfig(AppConfig): 7 | name = "modoboa_contacts" 8 | 9 | def ready(self): 10 | from . import handlers 11 | -------------------------------------------------------------------------------- /modoboa_contacts/constants.py: -------------------------------------------------------------------------------- 1 | """Contacts constants.""" 2 | 3 | from django.utils.translation import gettext_lazy 4 | 5 | EMAIL_TYPES = ( 6 | ("home", gettext_lazy("Home")), 7 | ("work", gettext_lazy("Work")), 8 | ("other", gettext_lazy("Other")) 9 | ) 10 | 11 | PHONE_TYPES = ( 12 | ("home", gettext_lazy("Home")), 13 | ("work", gettext_lazy("Work")), 14 | ("other", gettext_lazy("Other")), 15 | ("main", gettext_lazy("Main")), 16 | ("cell", gettext_lazy("Cellular")), 17 | ("fax", gettext_lazy("Fax")), 18 | ("pager", gettext_lazy("Pager")) 19 | ) 20 | 21 | CDAV_TO_MODEL_FIELDS_MAP = { 22 | "org": "company", 23 | "title": "position", 24 | "note": "note", 25 | } 26 | -------------------------------------------------------------------------------- /modoboa_contacts/factories.py: -------------------------------------------------------------------------------- 1 | """Contacts factories.""" 2 | 3 | import factory 4 | 5 | from modoboa.admin import factories as admin_factories 6 | 7 | from . import models 8 | 9 | 10 | class AddressBookFactory(factory.django.DjangoModelFactory): 11 | """Address book factory.""" 12 | 13 | class Meta: 14 | model = models.AddressBook 15 | 16 | user = factory.SubFactory(admin_factories.UserFactory) 17 | name = "Contacts" 18 | _path = "contacts" 19 | 20 | 21 | class CategoryFactory(factory.django.DjangoModelFactory): 22 | """Category factory.""" 23 | 24 | class Meta: 25 | model = models.Category 26 | 27 | 28 | class EmailAddressFactory(factory.django.DjangoModelFactory): 29 | """Email address factory.""" 30 | 31 | class Meta: 32 | model = models.EmailAddress 33 | 34 | type = "home" 35 | 36 | 37 | class PhoneNumberFactory(factory.django.DjangoModelFactory): 38 | """Phone number factory.""" 39 | 40 | class Meta: 41 | model = models.PhoneNumber 42 | 43 | type = "home" 44 | 45 | 46 | class ContactFactory(factory.django.DjangoModelFactory): 47 | """Contact factory.""" 48 | 49 | class Meta: 50 | model = models.Contact 51 | 52 | first_name = "Homer" 53 | last_name = "Simpson" 54 | display_name = factory.LazyAttribute( 55 | lambda c: "{}{}".format(c.first_name, c.last_name)) 56 | 57 | @factory.post_generation 58 | def categories(self, create, extracted, **dummy_kwargs): 59 | """Add categories to contact.""" 60 | if not create or not extracted: 61 | return 62 | for item in extracted: 63 | self.categories.add(item) 64 | 65 | @factory.post_generation 66 | def emails(self, create, extracted, **dummy_kwargs): 67 | """Add emails to contact.""" 68 | if not create or not extracted: 69 | return 70 | for item in extracted: 71 | EmailAddressFactory(contact=self, address=item) 72 | 73 | @factory.post_generation 74 | def phone_numbers(self, create, extracted, **dummy_kwargs): 75 | """Add phone numbers to contact.""" 76 | if not create or not extracted: 77 | return 78 | for item in extracted: 79 | PhoneNumberFactory(contact=self, number=item) 80 | -------------------------------------------------------------------------------- /modoboa_contacts/forms.py: -------------------------------------------------------------------------------- 1 | """Contacts forms.""" 2 | 3 | from django import forms 4 | from django.utils import timezone 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from modoboa.lib import cryptutils 8 | from modoboa.lib import form_utils 9 | from modoboa.lib import signals as lib_signals 10 | from modoboa.parameters import forms as param_forms 11 | 12 | from . import tasks 13 | 14 | 15 | class UserSettings(param_forms.UserParametersForm): 16 | """User settings.""" 17 | 18 | app = "modoboa_contacts" 19 | 20 | sep1 = form_utils.SeparatorField(label=_("Synchronization")) 21 | 22 | enable_carddav_sync = form_utils.YesNoField( 23 | initial=False, 24 | label=_("Synchonize address book using CardDAV"), 25 | help_text=_( 26 | "Choose to synchronize or not your address book using CardDAV. " 27 | "You will be able to access your contacts from the outside." 28 | ) 29 | ) 30 | 31 | sync_frequency = forms.IntegerField( 32 | initial=300, 33 | label=_("Synchronization frequency"), 34 | help_text=_( 35 | "Interval in seconds between 2 synchronization requests" 36 | ) 37 | ) 38 | 39 | visibility_rules = { 40 | "sync_frequency": "enable_carddav_sync=True" 41 | } 42 | 43 | def clean_sync_frequency(self): 44 | """Make sure frequency is a positive integer.""" 45 | if self.cleaned_data["sync_frequency"] < 60: 46 | raise forms.ValidationError( 47 | _("Minimum allowed value is 60s") 48 | ) 49 | return self.cleaned_data["sync_frequency"] 50 | 51 | def save(self, *args, **kwargs): 52 | """Create remote cal if necessary.""" 53 | super(UserSettings, self).save(*args, **kwargs) 54 | if not self.cleaned_data["enable_carddav_sync"]: 55 | return 56 | abook = self.user.addressbook_set.first() 57 | if abook.last_sync: 58 | return 59 | request = lib_signals.get_request() 60 | tasks.create_cdav_addressbook( 61 | abook, cryptutils.decrypt(request.session["password"])) 62 | if not abook.contact_set.exists(): 63 | abook.last_sync = timezone.now() 64 | abook.save(update_fields=["last_sync"]) 65 | -------------------------------------------------------------------------------- /modoboa_contacts/handlers.py: -------------------------------------------------------------------------------- 1 | """Webmail handlers.""" 2 | 3 | from django.db.models import signals 4 | from django.urls import reverse 5 | from django.dispatch import receiver 6 | from django.utils.translation import gettext as _ 7 | 8 | from modoboa.admin import models as admin_models 9 | from modoboa.core import signals as core_signals 10 | from modoboa.lib import signals as lib_signals 11 | 12 | from . import models 13 | 14 | 15 | @receiver(core_signals.extra_user_menu_entries) 16 | def menu(sender, location, user, **kwargs): 17 | """Return extra menu entry.""" 18 | if location != "top_menu" or not hasattr(user, "mailbox"): 19 | return [] 20 | return [ 21 | {"name": "contacts", 22 | "label": _("Contacts"), 23 | "url": reverse("modoboa_contacts:index")}, 24 | ] 25 | 26 | 27 | @receiver(core_signals.get_top_notifications) 28 | def check_addressbook_first_sync(sender, include_all, **kwargs): 29 | """Check if address book first sync has been made.""" 30 | request = lib_signals.get_request() 31 | qset = ( 32 | request.user.addressbook_set.filter(last_sync__isnull=True) 33 | ) 34 | condition = ( 35 | not request.user.parameters.get_value("enable_carddav_sync") or 36 | not qset.exists() 37 | ) 38 | if condition: 39 | return [] 40 | return [{ 41 | "id": "abook_sync_required", 42 | "url": reverse("modoboa_contacts:index"), 43 | "text": _("Your address book must be synced"), 44 | "level": "warning" 45 | }] 46 | 47 | 48 | @receiver(signals.post_save, sender=admin_models.Mailbox) 49 | def create_addressbook(sender, instance, created, **kwargs): 50 | """Create default address book for new mailbox.""" 51 | if not created: 52 | return 53 | models.AddressBook.objects.create( 54 | user=instance.user, name="Contacts", _path="contacts") 55 | 56 | 57 | @receiver(core_signals.extra_static_content) 58 | def inject_sync_poller(sender, caller, st_type, user, **kwargs): 59 | """Inject javascript code.""" 60 | condition = ( 61 | caller != "top" or 62 | st_type != "js" or 63 | not hasattr(user, "mailbox") or 64 | not user.parameters.get_value("enable_carddav_sync") 65 | ) 66 | if condition: 67 | return "" 68 | return """ 75 | """ % (reverse("api:addressbook-sync-from-cdav"), 76 | user.parameters.get_value("sync_frequency")) 77 | -------------------------------------------------------------------------------- /modoboa_contacts/importer/__init__.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from modoboa_contacts.importer.backends.outlook import OutlookBackend 4 | 5 | 6 | BACKENDS = [ 7 | OutlookBackend, 8 | ] 9 | 10 | 11 | def get_import_backend(fp, delimiter: str = ";", name: str = "auto"): 12 | reader = csv.DictReader( 13 | fp, 14 | delimiter=delimiter, 15 | skipinitialspace=True 16 | ) 17 | columns = reader.fieldnames 18 | rows = reader 19 | 20 | for backend in BACKENDS: 21 | if name == "auto": 22 | if backend.detect_from_columns(columns): 23 | return backend, rows 24 | elif name == backend.name: 25 | return backend, rows 26 | 27 | raise RuntimeError("Failed to detect backend to use") 28 | 29 | 30 | def import_csv_file(addressbook, 31 | backend_name: str, 32 | csv_filename: str, 33 | delimiter: str, 34 | carddav_password: str = None): 35 | with open(csv_filename) as fp: 36 | backend, rows = get_import_backend( 37 | fp, delimiter, backend_name) 38 | backend(addressbook).proceed(rows, carddav_password) 39 | -------------------------------------------------------------------------------- /modoboa_contacts/importer/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from modoboa_contacts import models, tasks 2 | 3 | 4 | class ImporterBackend: 5 | """Base class of all importer backends.""" 6 | 7 | name: str = None 8 | 9 | field_names: dict = {} 10 | 11 | def __init__(self, addressbook: models.AddressBook): 12 | self.addressbook = addressbook 13 | 14 | @classmethod 15 | def detect_from_columns(cls, columns: list) -> bool: 16 | raise NotImplementedError 17 | 18 | def get_email(self, values: dict): 19 | return None 20 | 21 | def get_phone_number(self, values: dict): 22 | return None 23 | 24 | def import_contact(self, row) -> models.Contact: 25 | contact = models.Contact(addressbook=self.addressbook) 26 | for local_name, row_name in self.field_names.items(): 27 | method_name = f"get_{local_name}" 28 | if hasattr(self, method_name): 29 | value = getattr(self, method_name)(row) 30 | else: 31 | value = row[row_name] 32 | setattr(contact, local_name, value) 33 | contact.save() 34 | if self.get_email(row): 35 | models.EmailAddress.objects.create( 36 | contact=contact, address=self.get_email(row), type="work" 37 | ) 38 | if self.get_phone_number(row): 39 | models.PhoneNumber.objects.create( 40 | contact=contact, number=self.get_phone_number(row), type="work" 41 | ) 42 | return contact 43 | 44 | def proceed(self, rows: list, carddav_password: str = None): 45 | for row in rows: 46 | contact = self.import_contact(row) 47 | if carddav_password: 48 | # FIXME: refactor CDAV tasks to allow connection from 49 | # credentials and not only request 50 | clt = tasks.get_cdav_client( 51 | self.addressbook, 52 | self.addressbook.user.email, 53 | carddav_password, 54 | True 55 | ) 56 | path, etag = clt.upload_new_card(contact.uid, contact.to_vcard()) 57 | contact.etag = etag 58 | contact.save(update_fields=["etag"]) 59 | -------------------------------------------------------------------------------- /modoboa_contacts/importer/backends/outlook.py: -------------------------------------------------------------------------------- 1 | from . import ImporterBackend 2 | 3 | 4 | OUTLOOK_COLUMNS = [ 5 | "First Name", 6 | "Middle Name", 7 | "Last Name", 8 | "Company", 9 | "E-mail Address", 10 | "Business Phone", 11 | "Business Street", 12 | "Business Street 2", 13 | "Business City", 14 | "Business State", 15 | "Business Postal Code" 16 | ] 17 | 18 | 19 | class OutlookBackend(ImporterBackend): 20 | """Outlook contact importer backend.""" 21 | 22 | name = "outlook" 23 | field_names = { 24 | "first_name": "", 25 | "last_name": "Last Name", 26 | "company": "Company", 27 | "address": "", 28 | "city": "Business City", 29 | "zipcode": "Business Postal Code", 30 | "state": "Business State", 31 | } 32 | 33 | @classmethod 34 | def detect_from_columns(cls, columns): 35 | return set(OUTLOOK_COLUMNS).issubset(columns) 36 | 37 | def get_first_name(self, values: dict) -> str: 38 | result = values["First Name"] 39 | if values["Middle Name"]: 40 | result += f" {values['Middle Name']}" 41 | return result 42 | 43 | def get_address(self, values: dict) -> str: 44 | result = values["Business Street"] 45 | if values["Business Street 2"]: 46 | result += f" {values['Business Street 2']}" 47 | return result 48 | 49 | def get_email(self, values: dict) -> str: 50 | return values["E-mail Address"] 51 | 52 | def get_phone_number(self, values: dict) -> str: 53 | return values["Business Phone"] 54 | -------------------------------------------------------------------------------- /modoboa_contacts/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-contacts/3620890134b1d6400a09104df83af9832d17fe8b/modoboa_contacts/lib/__init__.py -------------------------------------------------------------------------------- /modoboa_contacts/locale/br/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | # Translators: 7 | # Irriep Nala Novram , 2019 8 | # 9 | #, fuzzy 10 | msgid "" 11 | msgstr "" 12 | "Project-Id-Version: PACKAGE VERSION\n" 13 | "Report-Msgid-Bugs-To: \n" 14 | "POT-Creation-Date: 2018-10-12 11:02+0200\n" 15 | "PO-Revision-Date: 2018-10-12 09:06+0000\n" 16 | "Last-Translator: Irriep Nala Novram , 2019\n" 17 | "Language-Team: Breton (https://www.transifex.com/tonio/teams/13749/br/)\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Language: br\n" 22 | "Plural-Forms: nplurals=5; plural=((n%10 == 1) && (n%100 != 11) && (n%100 !=71) && (n%100 !=91) ? 0 :(n%10 == 2) && (n%100 != 12) && (n%100 !=72) && (n%100 !=92) ? 1 :(n%10 ==3 || n%10==4 || n%10==9) && (n%100 < 10 || n% 100 > 19) && (n%100 < 70 || n%100 > 79) && (n%100 < 90 || n%100 > 99) ? 2 :(n != 0 && n % 1000000 == 0) ? 3 : 4);\n" 23 | 24 | #: constants.py:6 constants.py:12 25 | msgid "Home" 26 | msgstr "Ti" 27 | 28 | #: constants.py:7 constants.py:13 29 | msgid "Work" 30 | msgstr "Labour" 31 | 32 | #: constants.py:8 constants.py:14 33 | msgid "Other" 34 | msgstr "Traoù all" 35 | 36 | #: constants.py:15 37 | msgid "Main" 38 | msgstr "Pennañ" 39 | 40 | #: constants.py:16 41 | msgid "Cellular" 42 | msgstr "Kelligel" 43 | 44 | #: constants.py:17 45 | msgid "Fax" 46 | msgstr "Faks" 47 | 48 | #: constants.py:18 49 | msgid "Pager" 50 | msgstr "Pajenner" 51 | 52 | #: forms.py:15 53 | msgid "Synchronization" 54 | msgstr "Sinkroneladur" 55 | 56 | #: forms.py:19 57 | msgid "Synchonize address book using CardDAV" 58 | msgstr "Sinkronelaat ho levr chomlec'hioù gant CardDAV" 59 | 60 | #: forms.py:21 61 | msgid "" 62 | "Choose to synchronize or not your address book using CardDAV. You will be " 63 | "able to access your contacts from the outside." 64 | msgstr "" 65 | "Dibabit sinkronelaat ho levr chomlec'hioù pe chom hep en ober gant CardDAV. " 66 | "Posupl e vo deoc'h tizhout ho tarempredoù adalek an diavaez." 67 | 68 | #: forms.py:28 69 | msgid "Synchronization frequency" 70 | msgstr "Frekañs sinkroneladur" 71 | 72 | #: forms.py:30 73 | msgid "Interval in seconds between 2 synchronization requests" 74 | msgstr "Padelezh en eilennoù etre 2 reked sinkroneladur" 75 | 76 | #: forms.py:42 77 | msgid "Minimum allowed value is 60s" 78 | msgstr "Talvoudegezh bihanañ aotreet a zo 60eil" 79 | 80 | #: handlers.py:24 modo_extension.py:17 modo_extension.py:25 81 | #: templates/modoboa_contacts/index.html:6 82 | msgid "Contacts" 83 | msgstr "Darempredoù" 84 | 85 | #: handlers.py:41 86 | msgid "Your address book must be synced" 87 | msgstr "Ho levr chomlec'hioù a rank bezañ sinkronelaet" 88 | 89 | #: models.py:36 90 | msgid "Server location is not set, please fix it." 91 | msgstr "Neket termenet lec'hiadur ho servijer, reizhit an dra-se mar plij." 92 | 93 | #: modo_extension.py:19 94 | msgid "Address book" 95 | msgstr "Levr chomlec'hioù" 96 | 97 | #: serializers.py:87 98 | msgid "Name or display name required" 99 | msgstr "Anv pe anv da ziskouez rekiset" 100 | -------------------------------------------------------------------------------- /modoboa_contacts/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-10-12 11:02+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: constants.py:6 constants.py:12 21 | msgid "Home" 22 | msgstr "" 23 | 24 | #: constants.py:7 constants.py:13 25 | msgid "Work" 26 | msgstr "" 27 | 28 | #: constants.py:8 constants.py:14 29 | msgid "Other" 30 | msgstr "" 31 | 32 | #: constants.py:15 33 | msgid "Main" 34 | msgstr "" 35 | 36 | #: constants.py:16 37 | msgid "Cellular" 38 | msgstr "" 39 | 40 | #: constants.py:17 41 | msgid "Fax" 42 | msgstr "" 43 | 44 | #: constants.py:18 45 | msgid "Pager" 46 | msgstr "" 47 | 48 | #: forms.py:15 49 | msgid "Synchronization" 50 | msgstr "" 51 | 52 | #: forms.py:19 53 | msgid "Synchonize address book using CardDAV" 54 | msgstr "" 55 | 56 | #: forms.py:21 57 | msgid "" 58 | "Choose to synchronize or not your address book using CardDAV. You will be " 59 | "able to access your contacts from the outside." 60 | msgstr "" 61 | 62 | #: forms.py:28 63 | msgid "Synchronization frequency" 64 | msgstr "" 65 | 66 | #: forms.py:30 67 | msgid "Interval in seconds between 2 synchronization requests" 68 | msgstr "" 69 | 70 | #: forms.py:42 71 | msgid "Minimum allowed value is 60s" 72 | msgstr "" 73 | 74 | #: handlers.py:24 modo_extension.py:17 modo_extension.py:25 75 | #: templates/modoboa_contacts/index.html:6 76 | msgid "Contacts" 77 | msgstr "" 78 | 79 | #: handlers.py:41 80 | msgid "Your address book must be synced" 81 | msgstr "" 82 | 83 | #: models.py:36 84 | msgid "Server location is not set, please fix it." 85 | msgstr "" 86 | 87 | #: modo_extension.py:19 88 | msgid "Address book" 89 | msgstr "" 90 | 91 | #: serializers.py:87 92 | msgid "Name or display name required" 93 | msgstr "" 94 | -------------------------------------------------------------------------------- /modoboa_contacts/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | # Translators: 7 | # Luis Fajardo López , 2018 8 | # 9 | #, fuzzy 10 | msgid "" 11 | msgstr "" 12 | "Project-Id-Version: PACKAGE VERSION\n" 13 | "Report-Msgid-Bugs-To: \n" 14 | "POT-Creation-Date: 2018-10-12 11:02+0200\n" 15 | "PO-Revision-Date: 2018-10-12 09:06+0000\n" 16 | "Last-Translator: Luis Fajardo López , 2018\n" 17 | "Language-Team: Spanish (https://www.transifex.com/tonio/teams/13749/es/)\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Language: es\n" 22 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 23 | 24 | #: constants.py:6 constants.py:12 25 | msgid "Home" 26 | msgstr "Casa" 27 | 28 | #: constants.py:7 constants.py:13 29 | msgid "Work" 30 | msgstr "Trabajo" 31 | 32 | #: constants.py:8 constants.py:14 33 | msgid "Other" 34 | msgstr "Otro" 35 | 36 | #: constants.py:15 37 | msgid "Main" 38 | msgstr "Principal" 39 | 40 | #: constants.py:16 41 | msgid "Cellular" 42 | msgstr "Móvil" 43 | 44 | #: constants.py:17 45 | msgid "Fax" 46 | msgstr "Fax" 47 | 48 | #: constants.py:18 49 | msgid "Pager" 50 | msgstr "Localizador" 51 | 52 | #: forms.py:15 53 | msgid "Synchronization" 54 | msgstr "Sincronización" 55 | 56 | #: forms.py:19 57 | msgid "Synchonize address book using CardDAV" 58 | msgstr "Sincronizar libro de direcciones usando CardDAV" 59 | 60 | #: forms.py:21 61 | msgid "" 62 | "Choose to synchronize or not your address book using CardDAV. You will be " 63 | "able to access your contacts from the outside." 64 | msgstr "" 65 | "Seleccione para sincronizar o no su libro de direcciones usando CardDAV. " 66 | "Podrá acceder a sus contactos remotamente." 67 | 68 | #: forms.py:28 69 | msgid "Synchronization frequency" 70 | msgstr "Frecuencia de sincronización" 71 | 72 | #: forms.py:30 73 | msgid "Interval in seconds between 2 synchronization requests" 74 | msgstr "Intervalo en segundos entre 2 peticiones de sincronización" 75 | 76 | #: forms.py:42 77 | msgid "Minimum allowed value is 60s" 78 | msgstr "El valor mínimo autorizado es 60s" 79 | 80 | #: handlers.py:24 modo_extension.py:17 modo_extension.py:25 81 | #: templates/modoboa_contacts/index.html:6 82 | msgid "Contacts" 83 | msgstr "Contactos" 84 | 85 | #: handlers.py:41 86 | msgid "Your address book must be synced" 87 | msgstr "Su libro de direcciones tiene que ser sincronizado" 88 | 89 | #: models.py:36 90 | msgid "Server location is not set, please fix it." 91 | msgstr "No se ha configurado la dirección del servidor, por favor corríjalo." 92 | 93 | #: modo_extension.py:19 94 | msgid "Address book" 95 | msgstr "Libro de direcciones" 96 | 97 | #: serializers.py:87 98 | msgid "Name or display name required" 99 | msgstr "Se requiere el nombre o el nombre a mostrar" 100 | -------------------------------------------------------------------------------- /modoboa_contacts/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2018-10-12 11:02+0200\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | 20 | #: constants.py:6 constants.py:12 21 | msgid "Home" 22 | msgstr "Personnel" 23 | 24 | #: constants.py:7 constants.py:13 25 | msgid "Work" 26 | msgstr "Professionnel" 27 | 28 | #: constants.py:8 constants.py:14 29 | msgid "Other" 30 | msgstr "Autre" 31 | 32 | #: constants.py:15 33 | msgid "Main" 34 | msgstr "Principal" 35 | 36 | #: constants.py:16 37 | msgid "Cellular" 38 | msgstr "Mobile" 39 | 40 | #: constants.py:17 41 | msgid "Fax" 42 | msgstr "Fax" 43 | 44 | #: constants.py:18 45 | msgid "Pager" 46 | msgstr "Pager" 47 | 48 | #: forms.py:15 49 | msgid "Synchronization" 50 | msgstr "Synchronisation" 51 | 52 | #: forms.py:19 53 | msgid "Synchonize address book using CardDAV" 54 | msgstr "Synchroniser le carnet d'adresse via CardDAV" 55 | 56 | #: forms.py:21 57 | msgid "" 58 | "Choose to synchronize or not your address book using CardDAV. You will be " 59 | "able to access your contacts from the outside." 60 | msgstr "" 61 | "Choisissez de synchroniser ou pas votre carnet d'adresse via CardDAV. Vous " 62 | "pourrez accéder à vos contacts depuis l'extérieur." 63 | 64 | #: forms.py:28 65 | msgid "Synchronization frequency" 66 | msgstr "Fréquence de synchronisation" 67 | 68 | #: forms.py:30 69 | msgid "Interval in seconds between 2 synchronization requests" 70 | msgstr "Intervalle en secondes entre 2 requêtes de synchronisation" 71 | 72 | #: forms.py:42 73 | msgid "Minimum allowed value is 60s" 74 | msgstr "La valeur minimale autorisée est 60s" 75 | 76 | #: handlers.py:24 modo_extension.py:17 modo_extension.py:25 77 | #: templates/modoboa_contacts/index.html:6 78 | msgid "Contacts" 79 | msgstr "Contacts" 80 | 81 | #: handlers.py:41 82 | msgid "Your address book must be synced" 83 | msgstr "Votre carnet d'adresse doit être synchronisé" 84 | 85 | #: models.py:36 86 | msgid "Server location is not set, please fix it." 87 | msgstr "L'adresse du serveur n'est pas définie, merci de le faire." 88 | 89 | #: modo_extension.py:19 90 | msgid "Address book" 91 | msgstr "Carnet d'adresse" 92 | 93 | #: serializers.py:87 94 | msgid "Name or display name required" 95 | msgstr "Nom ou nom affiché requis" 96 | -------------------------------------------------------------------------------- /modoboa_contacts/locale/it/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | # Translators: 7 | # marcotrevisan , 2018 8 | # 9 | #, fuzzy 10 | msgid "" 11 | msgstr "" 12 | "Project-Id-Version: PACKAGE VERSION\n" 13 | "Report-Msgid-Bugs-To: \n" 14 | "POT-Creation-Date: 2018-10-12 11:02+0200\n" 15 | "PO-Revision-Date: 2018-10-12 09:06+0000\n" 16 | "Last-Translator: marcotrevisan , 2018\n" 17 | "Language-Team: Italian (https://www.transifex.com/tonio/teams/13749/it/)\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Language: it\n" 22 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 23 | 24 | #: constants.py:6 constants.py:12 25 | msgid "Home" 26 | msgstr "Casa" 27 | 28 | #: constants.py:7 constants.py:13 29 | msgid "Work" 30 | msgstr "Lavoro" 31 | 32 | #: constants.py:8 constants.py:14 33 | msgid "Other" 34 | msgstr "Altro" 35 | 36 | #: constants.py:15 37 | msgid "Main" 38 | msgstr "Principale" 39 | 40 | #: constants.py:16 41 | msgid "Cellular" 42 | msgstr "Cellulare" 43 | 44 | #: constants.py:17 45 | msgid "Fax" 46 | msgstr "Fax" 47 | 48 | #: constants.py:18 49 | msgid "Pager" 50 | msgstr "Cercapersone" 51 | 52 | #: forms.py:15 53 | msgid "Synchronization" 54 | msgstr "Sincronizzazione" 55 | 56 | #: forms.py:19 57 | msgid "Synchonize address book using CardDAV" 58 | msgstr "Sincronizza la rubrica via CardDAV" 59 | 60 | #: forms.py:21 61 | msgid "" 62 | "Choose to synchronize or not your address book using CardDAV. You will be " 63 | "able to access your contacts from the outside." 64 | msgstr "" 65 | "Scegli se sincronizzare o no la tua rubrica via CardDAV. Ti permetterà di " 66 | "accedere ai tuoi contatti da applicazioni esterne." 67 | 68 | #: forms.py:28 69 | msgid "Synchronization frequency" 70 | msgstr "Frequenza di sincronizzazione" 71 | 72 | #: forms.py:30 73 | msgid "Interval in seconds between 2 synchronization requests" 74 | msgstr "Intervallo in secondi tra 2 richieste di sincronizzazione" 75 | 76 | #: forms.py:42 77 | msgid "Minimum allowed value is 60s" 78 | msgstr "Il minimo valore permesso è 60s" 79 | 80 | #: handlers.py:24 modo_extension.py:17 modo_extension.py:25 81 | #: templates/modoboa_contacts/index.html:6 82 | msgid "Contacts" 83 | msgstr "Contatti" 84 | 85 | #: handlers.py:41 86 | msgid "Your address book must be synced" 87 | msgstr "La tua rubrica deve essere sincronizzata" 88 | 89 | #: models.py:36 90 | msgid "Server location is not set, please fix it." 91 | msgstr "Posizione del server non impostata, si prega di correggere." 92 | 93 | #: modo_extension.py:19 94 | msgid "Address book" 95 | msgstr "Rubrica" 96 | 97 | #: serializers.py:87 98 | msgid "Name or display name required" 99 | msgstr "Nome, o nome visualizzato, richiesto" 100 | -------------------------------------------------------------------------------- /modoboa_contacts/locale/pl_PL/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-10-12 11:02+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Wojtek Gajda , 2018\n" 14 | "Language-Team: Polish (Poland) (https://www.transifex.com/tonio/teams/13749/pl_PL/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: pl_PL\n" 19 | "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" 20 | 21 | #: constants.py:6 constants.py:12 22 | msgid "Home" 23 | msgstr "Dom" 24 | 25 | #: constants.py:7 constants.py:13 26 | msgid "Work" 27 | msgstr "Praca" 28 | 29 | #: constants.py:8 constants.py:14 30 | msgid "Other" 31 | msgstr "Inny" 32 | 33 | #: constants.py:15 34 | msgid "Main" 35 | msgstr "Główny" 36 | 37 | #: constants.py:16 38 | msgid "Cellular" 39 | msgstr "Komórkowy" 40 | 41 | #: constants.py:17 42 | msgid "Fax" 43 | msgstr "Fax" 44 | 45 | #: constants.py:18 46 | msgid "Pager" 47 | msgstr "Pager" 48 | 49 | #: forms.py:15 50 | msgid "Synchronization" 51 | msgstr "Synchronizacja" 52 | 53 | #: forms.py:19 54 | msgid "Synchonize address book using CardDAV" 55 | msgstr "Synchronizuj książkę adresową używając CardDAV" 56 | 57 | #: forms.py:21 58 | msgid "" 59 | "Choose to synchronize or not your address book using CardDAV. You will be " 60 | "able to access your contacts from the outside." 61 | msgstr "" 62 | "Wybierz czy synchronizować lub nie Twoją książkę adresową używając CardDAV. " 63 | "Zyskasz możliwość dostępu do Twoich kontaktów z zewnątrz." 64 | 65 | #: forms.py:28 66 | msgid "Synchronization frequency" 67 | msgstr "Częstotliwość synchronizacji" 68 | 69 | #: forms.py:30 70 | msgid "Interval in seconds between 2 synchronization requests" 71 | msgstr "Odstęp pomiędzy synchronizacjami, w sekundach" 72 | 73 | #: forms.py:42 74 | msgid "Minimum allowed value is 60s" 75 | msgstr "Dopuszczalna minimalna wartość to 60" 76 | 77 | #: handlers.py:24 modo_extension.py:17 modo_extension.py:25 78 | #: templates/modoboa_contacts/index.html:6 79 | msgid "Contacts" 80 | msgstr "Kontakty" 81 | 82 | #: handlers.py:41 83 | msgid "Your address book must be synced" 84 | msgstr "Twoja książka adresowa musi być zsynchronizowana" 85 | 86 | #: models.py:36 87 | msgid "Server location is not set, please fix it." 88 | msgstr "Lokalizacja serwera nie jest ustawiona, należy to poprawić." 89 | 90 | #: modo_extension.py:19 91 | msgid "Address book" 92 | msgstr "Książka adresowa" 93 | 94 | #: serializers.py:87 95 | msgid "Name or display name required" 96 | msgstr "Nazwa albo wyświetlana nazwa jest wymagana" 97 | -------------------------------------------------------------------------------- /modoboa_contacts/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-contacts/3620890134b1d6400a09104df83af9832d17fe8b/modoboa_contacts/management/__init__.py -------------------------------------------------------------------------------- /modoboa_contacts/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-contacts/3620890134b1d6400a09104df83af9832d17fe8b/modoboa_contacts/management/commands/__init__.py -------------------------------------------------------------------------------- /modoboa_contacts/management/commands/import_contacts.py: -------------------------------------------------------------------------------- 1 | """Management command to import contacts from a CSV file.""" 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | from modoboa_contacts import models 6 | from modoboa_contacts.importer import import_csv_file 7 | 8 | 9 | class Command(BaseCommand): 10 | """Management command to import contacts.""" 11 | 12 | help = "Import contacts from a CSV file" 13 | 14 | def add_arguments(self, parser): 15 | parser.add_argument( 16 | "--delimiter", type=str, default=",", 17 | help="Delimiter used in CSV file" 18 | ) 19 | parser.add_argument( 20 | "--carddav-password", type=str, default=None, 21 | help=( 22 | "Password associated to email. If provided, imported " 23 | "contacts will be synced to CardDAV servert too" 24 | ) 25 | ) 26 | parser.add_argument( 27 | "--backend", type=str, default="auto", 28 | help=( 29 | "Specify import backend to use. Defaults to 'auto', " 30 | "meaning the script will try to guess which one to use" 31 | ) 32 | ) 33 | parser.add_argument( 34 | "email", type=str, 35 | help="Email address to import contacts for" 36 | ) 37 | parser.add_argument( 38 | "file", type=str, 39 | help="Path of the CSV file to import" 40 | ) 41 | 42 | def handle(self, *args, **options): 43 | addressbook = ( 44 | models.AddressBook.objects.filter( 45 | user__email=options["email"]).first() 46 | ) 47 | if not addressbook: 48 | raise CommandError( 49 | "Address Book for email '%s' not found" % options["email"] 50 | ) 51 | try: 52 | import_csv_file( 53 | addressbook, 54 | options["backend"], 55 | options["file"], 56 | options["delimiter"], 57 | options.get("carddav_password") 58 | ) 59 | except RuntimeError as err: 60 | raise CommandError(err) 61 | self.stdout.write( 62 | self.style.SUCCESS("File was imported successfuly") 63 | ) 64 | -------------------------------------------------------------------------------- /modoboa_contacts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-04-20 11:33 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Category', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('name', models.CharField(max_length=50)), 24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='Contact', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('first_name', models.CharField(blank=True, max_length=30)), 32 | ('last_name', models.CharField(blank=True, max_length=30)), 33 | ('display_name', models.CharField(blank=True, max_length=60)), 34 | ('birth_date', models.DateField(null=True)), 35 | ('company', models.CharField(blank=True, max_length=100)), 36 | ('position', models.CharField(blank=True, max_length=200)), 37 | ('address', models.CharField(blank=True, max_length=200)), 38 | ('zipcode', models.CharField(blank=True, max_length=15)), 39 | ('city', models.CharField(blank=True, max_length=100)), 40 | ('country', models.CharField(blank=True, max_length=100)), 41 | ('state', models.CharField(blank=True, max_length=100)), 42 | ('note', models.TextField(blank=True)), 43 | ('categories', models.ManyToManyField(blank=True, to='modoboa_contacts.Category')), 44 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 45 | ], 46 | ), 47 | migrations.CreateModel( 48 | name='EmailAddress', 49 | fields=[ 50 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 51 | ('address', models.EmailField(max_length=254)), 52 | ('type', models.CharField(choices=[(b'home', 'Home'), (b'work', 'Work'), (b'other', 'Other')], max_length=20)), 53 | ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emails', to='modoboa_contacts.Contact')), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name='PhoneNumber', 58 | fields=[ 59 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 60 | ('number', models.CharField(max_length=40)), 61 | ('type', models.CharField(choices=[(b'home', 'Home'), (b'work', 'Work'), (b'other', 'Other'), (b'main', 'Main'), (b'cellular', 'Cellular'), (b'fax', 'Fax'), (b'pager', 'Pager')], max_length=20)), 62 | ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phone_numbers', to='modoboa_contacts.Contact')), 63 | ], 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /modoboa_contacts/migrations/0002_auto_20180124_2311.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.8 on 2018-01-24 23:11 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('modoboa_contacts', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='emailaddress', 17 | name='type', 18 | field=models.CharField(choices=[('home', 'Home'), ('work', 'Work'), ('other', 'Other')], max_length=20), 19 | ), 20 | migrations.AlterField( 21 | model_name='phonenumber', 22 | name='type', 23 | field=models.CharField(choices=[('home', 'Home'), ('work', 'Work'), ('other', 'Other'), ('main', 'Main'), ('cellular', 'Cellular'), ('fax', 'Fax'), ('pager', 'Pager')], max_length=20), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /modoboa_contacts/migrations/0003_auto_20181005_1415.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-05 12:15 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('modoboa_contacts', '0002_auto_20180124_2311'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='AddressBook', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(max_length=50)), 23 | ('sync_token', models.TextField(blank=True)), 24 | ('last_sync', models.DateTimeField(null=True)), 25 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 26 | ('_path', models.TextField()), 27 | ], 28 | ), 29 | migrations.AddField( 30 | model_name='contact', 31 | name='uid', 32 | field=models.CharField(max_length=100, null=True, unique=True, db_index=True), 33 | ), 34 | migrations.AddField( 35 | model_name='contact', 36 | name='etag', 37 | field=models.TextField(blank=True), 38 | ), 39 | migrations.AddField( 40 | model_name='contact', 41 | name='addressbook', 42 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='modoboa_contacts.AddressBook'), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /modoboa_contacts/migrations/0004_auto_20181005_1415.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-05 12:15 3 | from __future__ import unicode_literals 4 | 5 | import uuid 6 | 7 | from django.db import migrations 8 | 9 | 10 | def create_address_books(apps, schema_editor): 11 | """Create address books for every account.""" 12 | User = apps.get_model("core", "User") 13 | AddressBook = apps.get_model("modoboa_contacts", "AddressBook") 14 | for user in User.objects.filter(mailbox__isnull=False): 15 | abook = AddressBook.objects.create( 16 | name="Contacts", user=user, _path="contacts") 17 | for contact in user.contact_set.all(): 18 | contact.addressbook = abook 19 | contact.uid = "{}.vcf".format(uuid.uuid4()) 20 | contact.save() 21 | 22 | 23 | def backward(apps, schema_editor): 24 | pass 25 | 26 | 27 | class Migration(migrations.Migration): 28 | 29 | dependencies = [ 30 | ('modoboa_contacts', '0003_auto_20181005_1415'), 31 | ] 32 | 33 | operations = [ 34 | migrations.RunPython(create_address_books, backward) 35 | ] 36 | -------------------------------------------------------------------------------- /modoboa_contacts/migrations/0005_auto_20181005_1445.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-05 12:45 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('modoboa_contacts', '0004_auto_20181005_1415'), 13 | ] 14 | 15 | operations = [ 16 | migrations.RemoveField( 17 | model_name='contact', 18 | name='user', 19 | ), 20 | migrations.AlterField( 21 | model_name='contact', 22 | name='addressbook', 23 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='modoboa_contacts.AddressBook'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /modoboa_contacts/migrations/0006_alter_phonenumber_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2023-02-11 16:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('modoboa_contacts', '0005_auto_20181005_1445'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='phonenumber', 15 | name='type', 16 | field=models.CharField(choices=[('home', 'Home'), ('work', 'Work'), ('other', 'Other'), ('main', 'Main'), ('cell', 'Cellular'), ('fax', 'Fax'), ('pager', 'Pager')], max_length=20), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /modoboa_contacts/migrations/0007_alter_contact_address.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-12-06 15:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('modoboa_contacts', '0006_alter_phonenumber_type'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='contact', 15 | name='address', 16 | field=models.TextField(blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /modoboa_contacts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-contacts/3620890134b1d6400a09104df83af9832d17fe8b/modoboa_contacts/migrations/__init__.py -------------------------------------------------------------------------------- /modoboa_contacts/mocks.py: -------------------------------------------------------------------------------- 1 | """Mocks to test the contacts plugin.""" 2 | 3 | import httmock 4 | 5 | 6 | @httmock.urlmatch(method="OPTIONS") 7 | def options_mock(url, request): 8 | """Simulate options request.""" 9 | return {"status_code": 200, "headers": {"DAV": "addressbook"}} 10 | 11 | 12 | @httmock.urlmatch(method="MKCOL") 13 | def mkcol_mock(url, request): 14 | """Simulate collection creation.""" 15 | return {"status_code": 201} 16 | 17 | 18 | @httmock.urlmatch(method="DELETE") 19 | def delete_mock(url, request): 20 | """Simulate a DELETE request.""" 21 | return {"status_code": 204} 22 | 23 | 24 | @httmock.urlmatch(method="PUT") 25 | def put_mock(url, request): 26 | """Simulate a PUT request.""" 27 | return {"status_code": 200, "headers": {"etag": '"12345"'}} 28 | 29 | 30 | @httmock.urlmatch(method="PROPFIND") 31 | def propfind_mock(url, request): 32 | """Simulate a PROPFIND request.""" 33 | content = b""" 34 | 35 | 36 | /radicale/user@test.com/contacts/ 37 | 38 | 39 | http://modoboa.org/ns/sync-token/3145 40 | 41 | HTTP/1.1 200 OK 42 | 43 | 44 | 45 | """ 46 | return {"status_code": 200, "content": content} 47 | 48 | 49 | @httmock.urlmatch(method="REPORT") 50 | def report_mock(url, request): 51 | """Simulate a REPORT request.""" 52 | if url.path.endswith(".vcf"): 53 | content = b""" 54 | 55 | 56 | /radicale/user@test.com/contacts/newcard.vcf 57 | 58 | 59 | "33441-34321" 60 | 61 | HTTP/1.1 200 OK 62 | 63 | 64 | 65 | """ 66 | else: 67 | content = b""" 68 | 69 | 70 | /radicale/user@test.com/contacts/newcard.vcf 71 | 72 | 73 | "33441-34321" 74 | 75 | HTTP/1.1 200 OK 76 | 77 | 78 | 79 | /radicale/user@test.com/contacts/updatedcard.vcf 80 | 81 | 82 | "33541-34696" 83 | 84 | HTTP/1.1 200 OK 85 | 86 | 87 | 88 | /radicale/user@test.com/contacts/deletedcard.vcf 89 | HTTP/1.1 404 Not Found 90 | 91 | http://modoboa.org/ns/sync/5001 92 | 93 | """ 94 | return {"status_code": 200, "content": content} 95 | 96 | 97 | @httmock.urlmatch(method="GET") 98 | def get_mock(url, request): 99 | """Simulate a GET request.""" 100 | uid = url.path.split("/")[-1] 101 | content = """ 102 | BEGIN:VCARD 103 | VERSION:3.0 104 | UID:{} 105 | N:Gump;Forrest 106 | FN:Forrest Gump 107 | ORG:Bubba Gump Shrimp Co. 108 | TITLE:Shrimp Man 109 | TEL;TYPE=WORK;VOICE:(111) 555-1212 110 | TEL;TYPE=HOME;VOICE:(404) 555-1212 111 | ADR;TYPE=HOME:;;42 Plantation St.;Baytown;LA;30314;United States of America 112 | EMAIL;TYPE=PREF,INTERNET:forrestgump@example.com 113 | END:VCARD 114 | """.format(uid) 115 | return {"status_code": 200, "content": content} 116 | -------------------------------------------------------------------------------- /modoboa_contacts/models.py: -------------------------------------------------------------------------------- 1 | """Contacts models.""" 2 | 3 | import os 4 | import uuid 5 | 6 | from dateutil.parser import parse 7 | import vobject 8 | 9 | from django.db import models 10 | from django.utils.translation import gettext as _ 11 | 12 | from modoboa.lib import exceptions as lib_exceptions 13 | from modoboa.parameters import tools as param_tools 14 | 15 | from . import constants 16 | from . import __version__ 17 | 18 | 19 | class AddressBook(models.Model): 20 | """An address book.""" 21 | 22 | name = models.CharField(max_length=50) 23 | sync_token = models.TextField(blank=True) 24 | last_sync = models.DateTimeField(null=True) 25 | user = models.ForeignKey("core.User", on_delete=models.CASCADE) 26 | _path = models.TextField() 27 | 28 | @property 29 | def url(self): 30 | server_location = param_tools.get_global_parameter( 31 | "server_location", app="modoboa_radicale") 32 | if not server_location: 33 | raise lib_exceptions.InternalError( 34 | _("Server location is not set, please fix it.")) 35 | return os.path.join(server_location, self.user.username, self._path) 36 | 37 | 38 | class Category(models.Model): 39 | """A category for contacts.""" 40 | 41 | user = models.ForeignKey("core.User", on_delete=models.CASCADE) 42 | name = models.CharField(max_length=50) 43 | 44 | 45 | class Contact(models.Model): 46 | """A contact.""" 47 | 48 | addressbook = models.ForeignKey(AddressBook, on_delete=models.CASCADE) 49 | uid = models.CharField( 50 | max_length=100, unique=True, null=True, db_index=True) 51 | # Can't define an index on etag field because of MySQL... 52 | etag = models.TextField(blank=True) 53 | first_name = models.CharField(max_length=30, blank=True) 54 | last_name = models.CharField(max_length=30, blank=True) 55 | display_name = models.CharField(max_length=60, blank=True) 56 | birth_date = models.DateField(null=True) 57 | 58 | company = models.CharField(max_length=100, blank=True) 59 | position = models.CharField(max_length=200, blank=True) 60 | 61 | address = models.TextField(blank=True) 62 | zipcode = models.CharField(max_length=15, blank=True) 63 | city = models.CharField(max_length=100, blank=True) 64 | country = models.CharField(max_length=100, blank=True) 65 | state = models.CharField(max_length=100, blank=True) 66 | 67 | note = models.TextField(blank=True) 68 | 69 | categories = models.ManyToManyField(Category, blank=True) 70 | 71 | def __init__(self, *args, **kwargs): 72 | """Set uid for new object.""" 73 | super().__init__(*args, **kwargs) 74 | if not self.pk: 75 | self.uid = "{}.vcf".format(uuid.uuid4()) 76 | 77 | @property 78 | def url(self): 79 | return "{}/{}.vcf".format(self.addressbook.url, self.uid) 80 | 81 | def to_vcard(self): 82 | """Convert this contact to a vCard.""" 83 | card = vobject.vCard() 84 | card.add("prodid").value = "-//Modoboa//Contacts plugin {}//EN".format( 85 | __version__) 86 | card.add("uid").value = self.uid 87 | card.add("n").value = vobject.vcard.Name( 88 | family=self.last_name, given=self.first_name) 89 | card.add("fn").value = self.display_name 90 | card.add("org").value = [self.company] 91 | card.add("title").value = self.position 92 | card.add("adr").value = vobject.vcard.Address( 93 | street=self.address, city=self.city, code=self.zipcode, 94 | country=self.country, region=self.state) 95 | if self.birth_date: 96 | card.add("bday").value = self.birth_date.isoformat() 97 | card.add("note").value = self.note 98 | for email in EmailAddress.objects.filter(contact=self): 99 | attr = card.add("email") 100 | attr.value = email.address 101 | attr.type_param = email.type 102 | for phone in PhoneNumber.objects.filter(contact=self): 103 | attr = card.add("tel") 104 | attr.value = phone.number 105 | attr.type_param = phone.type 106 | return card.serialize() 107 | 108 | def update_from_vcard(self, content): 109 | """Update this contact according to given vcard.""" 110 | vcard = vobject.readOne(content) 111 | self.uid = vcard.uid.value 112 | name = getattr(vcard, "n", None) 113 | if name: 114 | self.first_name = name.value.given 115 | self.last_name = name.value.family 116 | address = getattr(vcard, "adr", None) 117 | if address: 118 | self.address = address.value.street 119 | self.zipcode = address.value.code 120 | self.city = address.value.city 121 | self.state = address.value.region 122 | self.country = address.value.country 123 | birth_date = getattr(vcard, "bday", None) 124 | if birth_date: 125 | self.birth_date = parse(birth_date.value) 126 | for cfield, mfield in constants.CDAV_TO_MODEL_FIELDS_MAP.items(): 127 | value = getattr(vcard, cfield, None) 128 | if value: 129 | if isinstance(value.value, list): 130 | setattr(self, mfield, value.value[0]) 131 | else: 132 | setattr(self, mfield, value.value) 133 | self.save() 134 | email_list = getattr(vcard, "email_list", []) 135 | EmailAddress.objects.filter(contact=self).delete() 136 | to_create = [] 137 | for email in email_list: 138 | addr = EmailAddress(contact=self, address=email.value.lower()) 139 | if hasattr(email, "type_param"): 140 | addr.type = email.type_param.lower() 141 | to_create.append(addr) 142 | EmailAddress.objects.bulk_create(to_create) 143 | PhoneNumber.objects.filter(contact=self).delete() 144 | to_create = [] 145 | phone_list = getattr(vcard, "tel_list", []) 146 | for tel in phone_list: 147 | pnum = PhoneNumber(contact=self, number=tel.value.lower()) 148 | if hasattr(tel, "type_param"): 149 | pnum.type = tel.type_param.lower() 150 | to_create.append(pnum) 151 | PhoneNumber.objects.bulk_create(to_create) 152 | 153 | 154 | class EmailAddress(models.Model): 155 | """An email address.""" 156 | 157 | contact = models.ForeignKey(Contact, related_name="emails", 158 | on_delete=models.CASCADE) 159 | address = models.EmailField() 160 | type = models.CharField( 161 | max_length=20, choices=constants.EMAIL_TYPES) 162 | 163 | 164 | class PhoneNumber(models.Model): 165 | """A phone number.""" 166 | 167 | contact = models.ForeignKey(Contact, related_name="phone_numbers", 168 | on_delete=models.CASCADE) 169 | number = models.CharField(max_length=40) 170 | type = models.CharField( 171 | max_length=20, choices=constants.PHONE_TYPES) 172 | 173 | def __str__(self): 174 | return "{}: {}".format(self.type, self.number) 175 | -------------------------------------------------------------------------------- /modoboa_contacts/modo_extension.py: -------------------------------------------------------------------------------- 1 | """Declare and register the contacts extension.""" 2 | 3 | from django.urls import reverse_lazy 4 | from django.utils.translation import gettext_lazy 5 | 6 | from modoboa.core.extensions import ModoExtension, exts_pool 7 | from modoboa.parameters import tools as param_tools 8 | 9 | from . import __version__ 10 | from . import forms 11 | 12 | 13 | class Contacts(ModoExtension): 14 | """Plugin declaration.""" 15 | 16 | name = "modoboa_contacts" 17 | label = gettext_lazy("Contacts") 18 | version = __version__ 19 | description = gettext_lazy("Address book") 20 | url = "contacts" 21 | topredirection_url = reverse_lazy("modoboa_contacts:index") 22 | 23 | def load(self): 24 | param_tools.registry.add( 25 | "user", forms.UserSettings, gettext_lazy("Contacts")) 26 | 27 | 28 | exts_pool.register_extension(Contacts) 29 | -------------------------------------------------------------------------------- /modoboa_contacts/serializers.py: -------------------------------------------------------------------------------- 1 | """Contacts serializers.""" 2 | 3 | from django.utils.translation import gettext as _ 4 | 5 | from rest_framework import serializers 6 | 7 | from . import models 8 | from . import tasks 9 | 10 | 11 | class AddressBookSerializer(serializers.ModelSerializer): 12 | """Address book serializer.""" 13 | 14 | class Meta: 15 | model = models.AddressBook 16 | fields = ("pk", "name", "url") 17 | 18 | 19 | class EmailAddressSerializer(serializers.ModelSerializer): 20 | """Email address serializer.""" 21 | 22 | class Meta: 23 | model = models.EmailAddress 24 | fields = ("pk", "address", "type") 25 | 26 | 27 | class EmailAddressWithNameSerializer(serializers.ModelSerializer): 28 | """Email address + contact name serializer.""" 29 | 30 | display_name = serializers.SerializerMethodField() 31 | 32 | class Meta: 33 | model = models.EmailAddress 34 | fields = ("pk", "address", "type", "display_name") 35 | 36 | def get_display_name(self, obj): 37 | """Return display name.""" 38 | if obj.contact.display_name: 39 | return obj.contact.display_name 40 | return u"{} {}".format(obj.contact.first_name, obj.contact.last_name) 41 | 42 | 43 | class PhoneNumberSerializer(serializers.ModelSerializer): 44 | """Phone number serializer.""" 45 | 46 | class Meta: 47 | model = models.PhoneNumber 48 | fields = ("pk", "number", "type") 49 | 50 | 51 | class CategorySerializer(serializers.ModelSerializer): 52 | """Serializer for Category.""" 53 | 54 | class Meta: 55 | model = models.Category 56 | fields = ("pk", "name") 57 | 58 | def create(self, validated_data): 59 | """Use current user.""" 60 | user = self.context["request"].user 61 | return models.Category.objects.create(user=user, **validated_data) 62 | 63 | 64 | class ContactSerializer(serializers.ModelSerializer): 65 | """Contact serializer.""" 66 | 67 | emails = EmailAddressSerializer(many=True) 68 | phone_numbers = PhoneNumberSerializer(many=True, required=False) 69 | 70 | class Meta: 71 | model = models.Contact 72 | fields = ( 73 | "pk", "first_name", "last_name", "categories", "emails", 74 | "phone_numbers", "company", "position", 75 | "address", "zipcode", "city", "country", "state", 76 | "note", "birth_date", "display_name" 77 | ) 78 | 79 | def validate(self, data): 80 | """Make sure display name or first/last names are set.""" 81 | condition = ( 82 | not data.get("first_name") and 83 | not data.get("last_name") and 84 | not data.get("display_name") 85 | ) 86 | if condition: 87 | msg = _("Name or display name required") 88 | raise serializers.ValidationError({ 89 | "first_name": msg, 90 | "last_name": msg, 91 | "display_name": msg 92 | }) 93 | if not data.get("display_name"): 94 | data["display_name"] = data.get("first_name", "") 95 | if data["display_name"]: 96 | data["display_name"] += " " 97 | data["display_name"] += data.get("last_name", "") 98 | return data 99 | 100 | def create(self, validated_data): 101 | """Use current user.""" 102 | request = self.context["request"] 103 | addressbook = request.user.addressbook_set.first() 104 | categories = validated_data.pop("categories", []) 105 | emails = validated_data.pop("emails") 106 | phone_numbers = validated_data.pop("phone_numbers", []) 107 | contact = models.Contact.objects.create( 108 | addressbook=addressbook, **validated_data) 109 | to_create = [] 110 | for email in emails: 111 | to_create.append(models.EmailAddress(contact=contact, **email)) 112 | models.EmailAddress.objects.bulk_create(to_create) 113 | to_create = [] 114 | for phone_number in phone_numbers: 115 | to_create.append( 116 | models.PhoneNumber(contact=contact, **phone_number)) 117 | if to_create: 118 | models.PhoneNumber.objects.bulk_create(to_create) 119 | if categories: 120 | for category in categories: 121 | contact.categories.add(category) 122 | condition = ( 123 | addressbook.last_sync and 124 | addressbook.user.parameters.get_value("enable_carddav_sync") 125 | ) 126 | if condition: 127 | tasks.push_contact_to_cdav(request, contact) 128 | return contact 129 | 130 | def update_emails(self, instance, emails): 131 | """Update instance emails.""" 132 | local_addresses = [] 133 | local_objects = [] 134 | for email in instance.emails.all(): 135 | local_addresses.append(email.address) 136 | local_objects.append(email) 137 | to_create = [] 138 | for email in emails: 139 | if email["address"] not in local_addresses: 140 | to_create.append( 141 | models.EmailAddress(contact=instance, **email)) 142 | continue 143 | index = local_addresses.index(email["address"]) 144 | local_email = local_objects[index] 145 | condition = ( 146 | local_email.type != email["type"] or 147 | local_email.address != email["address"]) 148 | if condition: 149 | local_email.type = email["type"] 150 | local_email.address = email["address"] 151 | local_email.save() 152 | local_addresses.pop(index) 153 | local_objects.pop(index) 154 | models.EmailAddress.objects.filter( 155 | pk__in=[email.pk for email in local_objects]).delete() 156 | models.EmailAddress.objects.bulk_create(to_create) 157 | 158 | def update_phone_numbers(self, instance, phone_numbers): 159 | """Update instance phone numbers.""" 160 | local_phones = [] 161 | local_objects = [] 162 | for phone in instance.phone_numbers.all(): 163 | local_phones.append(phone.number) 164 | local_objects.append(phone) 165 | to_create = [] 166 | for phone in phone_numbers: 167 | if phone["number"] not in local_phones: 168 | to_create.append( 169 | models.PhoneNumber(contact=instance, **phone)) 170 | continue 171 | index = local_phones.index(phone["number"]) 172 | local_phone = local_objects[index] 173 | condition = ( 174 | local_phone.type != phone["type"] or 175 | local_phone.number != phone["number"]) 176 | if condition: 177 | local_phone.type = phone["type"] 178 | local_phone.number = phone["number"] 179 | local_phone.save() 180 | local_phones.pop(index) 181 | local_objects.pop(index) 182 | instance.phone_numbers.filter( 183 | pk__in=[phone.pk for phone in local_objects]).delete() 184 | models.PhoneNumber.objects.bulk_create(to_create) 185 | 186 | def update(self, instance, validated_data): 187 | """Update contact.""" 188 | emails = validated_data.pop("emails") 189 | phone_numbers = validated_data.pop("phone_numbers", []) 190 | categories = validated_data.pop("categories", []) 191 | for key, value in validated_data.items(): 192 | setattr(instance, key, value) 193 | instance.categories.set(categories) 194 | instance.save() 195 | 196 | self.update_emails(instance, emails) 197 | self.update_phone_numbers(instance, phone_numbers) 198 | 199 | condition = ( 200 | instance.addressbook.last_sync and 201 | instance.addressbook.user.parameters.get_value( 202 | "enable_carddav_sync") 203 | ) 204 | if condition: 205 | tasks.update_contact_cdav(self.context["request"], instance) 206 | 207 | return instance 208 | -------------------------------------------------------------------------------- /modoboa_contacts/settings.py: -------------------------------------------------------------------------------- 1 | """Default Contacts settings.""" 2 | 3 | import os 4 | 5 | PLUGIN_BASE_DIR = os.path.dirname(__file__) 6 | 7 | CONTACTS_STATS_FILE = os.path.join( 8 | PLUGIN_BASE_DIR, "static/modoboa_contacts/webpack-stats.json") 9 | 10 | 11 | def apply(settings): 12 | """Modify settings.""" 13 | DEBUG = settings["DEBUG"] 14 | if "webpack_loader" not in settings["INSTALLED_APPS"]: 15 | settings["INSTALLED_APPS"] += ("webpack_loader", ) 16 | wpl_config = { 17 | "CONTACTS": { 18 | "CACHE": not DEBUG, 19 | "BUNDLE_DIR_NAME": "modoboa_contacts/", 20 | "STATS_FILE": CONTACTS_STATS_FILE, 21 | "IGNORE": [".+\.hot-update.js", ".+\.map"] 22 | } 23 | } 24 | if "WEBPACK_LOADER" in settings: 25 | settings["WEBPACK_LOADER"].update(wpl_config) 26 | else: 27 | settings["WEBPACK_LOADER"] = wpl_config 28 | -------------------------------------------------------------------------------- /modoboa_contacts/tasks.py: -------------------------------------------------------------------------------- 1 | """Async tasks.""" 2 | 3 | from django.utils import timezone 4 | 5 | from modoboa.lib import cryptutils 6 | 7 | from .lib import carddav 8 | from . import models 9 | 10 | 11 | def get_cdav_client(addressbook, user: str, passwd: str, write_support=False): 12 | """Instantiate a new CardDAV client.""" 13 | return carddav.PyCardDAV( 14 | addressbook.url, 15 | user=user, 16 | passwd=passwd, 17 | write_support=write_support 18 | ) 19 | 20 | 21 | def get_cdav_client_from_request(request, addressbook, *args, **kwargs): 22 | """Create a connection from a Request object.""" 23 | return get_cdav_client( 24 | addressbook, 25 | request.user.username, 26 | passwd=cryptutils.decrypt(request.session["password"]), 27 | **kwargs 28 | ) 29 | 30 | 31 | def create_cdav_addressbook(addressbook, password): 32 | """Create CardDAV address book.""" 33 | clt = carddav.PyCardDAV( 34 | addressbook.url, user=addressbook.user.username, 35 | passwd=password, 36 | write_support=True 37 | ) 38 | clt.create_abook() 39 | 40 | 41 | def push_addressbook_to_carddav(request, addressbook): 42 | """Push every addressbook item to carddav collection. 43 | 44 | Use only once. 45 | """ 46 | clt = get_cdav_client_from_request(request, addressbook, write_support=True) 47 | for contact in addressbook.contact_set.all(): 48 | href, etag = clt.upload_new_card(contact.uid, contact.to_vcard()) 49 | contact.etag = etag 50 | contact.save(update_fields=["etag"]) 51 | addressbook.last_sync = timezone.now() 52 | addressbook.sync_token = clt.get_sync_token() 53 | addressbook.save(update_fields=["last_sync", "sync_token"]) 54 | 55 | 56 | def sync_addressbook_from_cdav(request, addressbook): 57 | """Fetch changes from CardDAV server.""" 58 | clt = get_cdav_client_from_request(request, addressbook) 59 | changes = clt.sync_vcards(addressbook.sync_token) 60 | if not len(changes["cards"]): 61 | return 62 | for card in changes["cards"]: 63 | # UID sometimes embded .vcf extension, sometimes not... 64 | long_uid = card["href"].split("/")[-1] 65 | short_uid = long_uid.split(".")[0] 66 | if "200" in card["status"]: 67 | content = clt.get_vcard(card["href"]).decode() 68 | contact = models.Contact.objects.filter( 69 | uid__in=[long_uid, short_uid]).first() 70 | if not contact: 71 | contact = models.Contact(addressbook=addressbook) 72 | if contact.etag != card["etag"]: 73 | contact.etag = card["etag"] 74 | contact.update_from_vcard(content) 75 | elif "404" in card["status"]: 76 | models.Contact.objects.filter( 77 | uid__in=[long_uid, short_uid]).delete() 78 | addressbook.last_sync = timezone.now() 79 | addressbook.sync_token = changes["token"] 80 | addressbook.save(update_fields=["last_sync", "sync_token"]) 81 | 82 | 83 | def push_contact_to_cdav(request, contact): 84 | """Upload new contact to cdav collection.""" 85 | clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True) 86 | path, etag = clt.upload_new_card(contact.uid, contact.to_vcard()) 87 | contact.etag = etag 88 | contact.save(update_fields=["etag"]) 89 | 90 | 91 | def update_contact_cdav(request, contact): 92 | """Update existing contact.""" 93 | clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True) 94 | uid = contact.uid 95 | if not uid.endswith(".vcf"): 96 | uid += ".vcf" 97 | result = clt.update_vcard(contact.to_vcard(), uid, contact.etag) 98 | contact.etag = result["cards"][0]["etag"] 99 | contact.save(update_fields=["etag"]) 100 | 101 | 102 | def delete_contact_cdav(request, contact): 103 | """Delete a contact.""" 104 | clt = get_cdav_client_from_request(request, contact.addressbook, write_support=True) 105 | uid = contact.uid 106 | if not uid.endswith(".vcf"): 107 | uid += ".vcf" 108 | clt.delete_vcard(uid, contact.etag) 109 | -------------------------------------------------------------------------------- /modoboa_contacts/templates/modoboa_contacts/index.html: -------------------------------------------------------------------------------- 1 | {% extends "fluid.html" %} 2 | 3 | {% load i18n lib_tags static %} 4 | {% load render_bundle from webpack_loader %} 5 | 6 | {% block pagetitle %}{% trans "Contacts" %}{% endblock %} 7 | 8 | {% block extra_css %} 9 | {% if not debug %} 10 | {% render_bundle 'app' 'css' 'CONTACTS' %} 11 | {% endif %} 12 | {% endblock %} 13 | 14 | {% block extra_js %} 15 | {% if sync_enabled %} 16 | 21 | {% endif %} 22 | {% render_bundle 'app' 'js' 'CONTACTS' %} 23 | {% endblock %} 24 | 25 | {% block container_content %} 26 | 27 |
28 |
29 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /modoboa_contacts/test_data/outlook_export.csv: -------------------------------------------------------------------------------- 1 | "First Name","Middle Name","Last Name","Company","E-mail Address","Business Phone","Business Street","Business Street 2","Business City","Business State","Business Postal Code" 2 | Toto,Tata,Titi,Company,toto@titi.com,12345678,Street 1,Street 2,City,State,France 3 | -------------------------------------------------------------------------------- /modoboa_contacts/test_data/unknown_export.csv: -------------------------------------------------------------------------------- 1 | "First Name","Middle Name","Last Name","Company","E-mail Address","Business Phone","Business Street","Business Street 2","City","Business State","Business Postal Code" 2 | Toto,Tata,Titi,Company,toto@titi.com,12345678,Street 1,Street 2,City,State,France 3 | -------------------------------------------------------------------------------- /modoboa_contacts/urls.py: -------------------------------------------------------------------------------- 1 | """Contacts urls.""" 2 | 3 | from django.urls import path 4 | 5 | from . import views 6 | 7 | app_name = "modoboa_contacts" 8 | 9 | urlpatterns = [ 10 | path('', views.IndexView.as_view(), name="index") 11 | ] 12 | -------------------------------------------------------------------------------- /modoboa_contacts/urls_api.py: -------------------------------------------------------------------------------- 1 | """Contacts API urls.""" 2 | 3 | from rest_framework import routers 4 | 5 | from . import viewsets 6 | 7 | 8 | router = routers.SimpleRouter() 9 | router.register( 10 | r"address-books", viewsets.AddressBookViewSet, basename="addressbook") 11 | router.register(r"categories", viewsets.CategoryViewSet, basename="category") 12 | router.register(r"contacts", viewsets.ContactViewSet, basename="contact") 13 | router.register( 14 | r"emails", viewsets.EmailAddressViewSet, basename="emailaddress") 15 | 16 | urlpatterns = router.urls 17 | -------------------------------------------------------------------------------- /modoboa_contacts/version.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def local_scheme(version): 5 | return "" 6 | 7 | 8 | def get_version(): 9 | from setuptools_scm import get_version as default_version 10 | 11 | github_version = os.environ.get("GITHUB_REF_NAME", None) 12 | github_type = os.environ.get("GITHUB_REF_TYPE", None) 13 | if github_version is not None and github_type == "tag": 14 | print(f"GITHUB_REF_NAME found, using version: {github_version}") 15 | return github_version 16 | return default_version(local_scheme=local_scheme) 17 | -------------------------------------------------------------------------------- /modoboa_contacts/views.py: -------------------------------------------------------------------------------- 1 | """Contacts views.""" 2 | 3 | from django.views import generic 4 | 5 | from django.contrib.auth import mixins as auth_mixins 6 | 7 | 8 | class IndexView(auth_mixins.LoginRequiredMixin, generic.TemplateView): 9 | """Simple view to display index page.""" 10 | 11 | template_name = "modoboa_contacts/index.html" 12 | 13 | def get_context_data(self, **kwargs): 14 | """Set menu selection.""" 15 | context = super(IndexView, self).get_context_data(**kwargs) 16 | context.update({ 17 | "selection": "contacts", 18 | "sync_enabled": self.request.user.parameters.get_value( 19 | "enable_carddav_sync"), 20 | "abook_synced": self.request.user.addressbook_set.filter( 21 | last_sync__isnull=False).exists() 22 | }) 23 | return context 24 | -------------------------------------------------------------------------------- /modoboa_contacts/viewsets.py: -------------------------------------------------------------------------------- 1 | """Contacts viewsets.""" 2 | 3 | import django_filters.rest_framework 4 | from rest_framework.decorators import action 5 | from rest_framework import filters, response, viewsets 6 | from rest_framework.permissions import IsAuthenticated 7 | 8 | from . import models 9 | from . import serializers 10 | from . import tasks 11 | 12 | 13 | class AddressBookViewSet(viewsets.GenericViewSet): 14 | """Address book viewset.""" 15 | 16 | permission_classes = (IsAuthenticated, ) 17 | serializer_class = serializers.AddressBookSerializer 18 | 19 | @action(methods=["get"], detail=False) 20 | def default(self, request): 21 | """Return default user address book.""" 22 | abook = request.user.addressbook_set.first() 23 | if not abook: 24 | return response.Response(status_code=404) 25 | serializer = serializers.AddressBookSerializer(abook) 26 | return response.Response(serializer.data) 27 | 28 | @action(methods=["get"], detail=False) 29 | def sync_to_cdav(self, request): 30 | """Synchronize address book with CardDAV collection.""" 31 | abook = request.user.addressbook_set.first() 32 | if request.user.parameters.get_value("enable_carddav_sync"): 33 | tasks.push_addressbook_to_carddav(request, abook) 34 | return response.Response({}) 35 | 36 | @action(methods=["get"], detail=False) 37 | def sync_from_cdav(self, request): 38 | """Synchronize from CardDAV address book.""" 39 | abook = request.user.addressbook_set.first() 40 | if not abook.last_sync: 41 | return response.Response() 42 | if request.user.parameters.get_value("enable_carddav_sync"): 43 | tasks.sync_addressbook_from_cdav(request, abook) 44 | return response.Response({}) 45 | 46 | 47 | class CategoryViewSet(viewsets.ModelViewSet): 48 | """Category ViewSet.""" 49 | 50 | permission_classes = [IsAuthenticated] 51 | serializer_class = serializers.CategorySerializer 52 | 53 | def get_queryset(self): 54 | """Filter based on current user.""" 55 | qset = models.Category.objects.filter(user=self.request.user) 56 | return qset.select_related("user") 57 | 58 | 59 | class ContactFilter(django_filters.rest_framework.FilterSet): 60 | """Filter for Contact.""" 61 | 62 | category = django_filters.CharFilter(field_name="categories__name") 63 | 64 | class Meta: 65 | model = models.Contact 66 | fields = ["categories"] 67 | 68 | 69 | class ContactViewSet(viewsets.ModelViewSet): 70 | """Contact ViewSet.""" 71 | 72 | filter_backends = [ 73 | filters.SearchFilter, 74 | django_filters.rest_framework.DjangoFilterBackend] 75 | filterset_class = ContactFilter 76 | permission_classes = [IsAuthenticated] 77 | search_fields = ("^first_name", "^last_name", "^emails__address") 78 | serializer_class = serializers.ContactSerializer 79 | 80 | def get_queryset(self): 81 | """Filter based on current user.""" 82 | qset = models.Contact.objects.filter( 83 | addressbook__user=self.request.user) 84 | return qset.prefetch_related( 85 | "categories", "emails", "phone_numbers") 86 | 87 | def perform_destroy(self, instance): 88 | """Also remove cdav contact.""" 89 | if self.request.user.parameters.get_value("enable_carddav_sync"): 90 | tasks.delete_contact_cdav(self.request, instance) 91 | instance.delete() 92 | 93 | 94 | class EmailAddressViewSet(viewsets.ReadOnlyModelViewSet): 95 | """EmailAddress viewset.""" 96 | 97 | filter_backends = [filters.SearchFilter] 98 | permission_classes = [IsAuthenticated] 99 | search_fields = ( 100 | "^address", "^contact__display_name", 101 | "^contact__first_name", "^contact__last_name", 102 | ) 103 | serializer_class = serializers.EmailAddressWithNameSerializer 104 | 105 | def get_queryset(self): 106 | """Filter based on current user.""" 107 | return models.EmailAddress.objects.filter( 108 | contact__addressbook__user=self.request.user) 109 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=CVS,migrations 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | [MESSAGES CONTROL] 25 | 26 | # Enable the message, report, category or checker with the given id(s). You can 27 | # either give multiple identifier separated by comma (,) or put this option 28 | # multiple time. See also the "--disable" option for examples. 29 | #enable= 30 | 31 | # Disable the message, report, category or checker with the given id(s). You 32 | # can either give multiple identifiers separated by comma (,) or put this 33 | # option multiple times (only on the command line, not in the configuration 34 | # file where it should appear only once).You can also use "--disable=all" to 35 | # disable everything first and then reenable specific checks. For example, if 36 | # you want to run only the similarities checker, you can use "--disable=all 37 | # --enable=similarities". If you want to run only the classes checker, but have 38 | # no Warning level messages displayed, use"--disable=all --enable=classes 39 | # --disable=W" 40 | disable=no-init,no-member,locally-disabled,too-few-public-methods,too-many-public-methods,too-many-ancestors 41 | 42 | 43 | [REPORTS] 44 | 45 | # Set the output format. Available formats are text, parseable, colorized, msvs 46 | # (visual studio) and html. You can also give a reporter class, eg 47 | # mypackage.mymodule.MyReporterClass. 48 | output-format=text 49 | 50 | # Put messages in a separate file for each module / package specified on the 51 | # command line instead of printing them on stdout. Reports (if any) will be 52 | # written in a file name "pylint_global.[txt|html]". 53 | files-output=no 54 | 55 | # Tells whether to display a full report or only the messages 56 | reports=yes 57 | 58 | # Python expression which should return a note less than 10 (10 is the highest 59 | # note). You have access to the variables errors warning, statement which 60 | # respectively contain the number of errors / warnings messages and the total 61 | # number of statements analyzed. This is used by the global evaluation report 62 | # (RP0004). 63 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 64 | 65 | # Add a comment according to your evaluation note. This is used by the global 66 | # evaluation report (RP0004). 67 | comment=no 68 | 69 | # Template used to display messages. This is a python new-style format string 70 | # used to format the message information. See doc for all details 71 | #msg-template= 72 | 73 | 74 | [LOGGING] 75 | 76 | # Logging modules to check that the string format arguments are in logging 77 | # function parameter format 78 | logging-modules=logging 79 | 80 | 81 | [FORMAT] 82 | 83 | # Maximum number of characters on a single line. 84 | max-line-length=80 85 | 86 | # Regexp for a line that is allowed to be longer than the limit. 87 | ignore-long-lines=^\s*(# )??$ 88 | 89 | # Allow the body of an if to be on the same line as the test if there is no 90 | # else. 91 | single-line-if-stmt=no 92 | 93 | # List of optional constructs for which whitespace checking is disabled 94 | no-space-check=trailing-comma,dict-separator 95 | 96 | # Maximum number of lines in a module 97 | max-module-lines=1000 98 | 99 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 100 | # tab). 101 | indent-string=' ' 102 | 103 | # Number of spaces of indent required inside a hanging or continued line. 104 | indent-after-paren=4 105 | 106 | 107 | [SIMILARITIES] 108 | 109 | # Minimum lines number of a similarity. 110 | min-similarity-lines=4 111 | 112 | # Ignore comments when computing similarities. 113 | ignore-comments=yes 114 | 115 | # Ignore docstrings when computing similarities. 116 | ignore-docstrings=yes 117 | 118 | # Ignore imports when computing similarities. 119 | ignore-imports=no 120 | 121 | 122 | [MISCELLANEOUS] 123 | 124 | # List of note tags to take in consideration, separated by a comma. 125 | notes=FIXME,XXX,TODO 126 | 127 | 128 | [BASIC] 129 | 130 | # Required attributes for module, separated by a comma 131 | required-attributes= 132 | 133 | # List of builtins function names that should not be used, separated by a comma 134 | bad-functions=map,filter,apply,input,file 135 | 136 | # Good variable names which should always be accepted, separated by a comma 137 | good-names=i,j,k,ex,Run,_,tz,logger,pk,qs 138 | 139 | # Bad variable names which should always be refused, separated by a comma 140 | bad-names=foo,bar,baz,toto,tutu,tata 141 | 142 | # Colon-delimited sets of names that determine each other's naming style when 143 | # the name regexes allow several styles. 144 | name-group= 145 | 146 | # Include a hint for the correct naming format with invalid-name 147 | include-naming-hint=no 148 | 149 | # Regular expression matching correct function names 150 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 151 | 152 | # Naming hint for function names 153 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 154 | 155 | # Regular expression matching correct variable names 156 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 157 | 158 | # Naming hint for variable names 159 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 160 | 161 | # Regular expression matching correct constant names 162 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 163 | 164 | # Naming hint for constant names 165 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 166 | 167 | # Regular expression matching correct attribute names 168 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 169 | 170 | # Naming hint for attribute names 171 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 172 | 173 | # Regular expression matching correct argument names 174 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 175 | 176 | # Naming hint for argument names 177 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 178 | 179 | # Regular expression matching correct class attribute names 180 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 181 | 182 | # Naming hint for class attribute names 183 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 184 | 185 | # Regular expression matching correct inline iteration names 186 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 187 | 188 | # Naming hint for inline iteration names 189 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 190 | 191 | # Regular expression matching correct class names 192 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 193 | 194 | # Naming hint for class names 195 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 196 | 197 | # Regular expression matching correct module names 198 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 199 | 200 | # Naming hint for module names 201 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 202 | 203 | # Regular expression matching correct method names 204 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 205 | 206 | # Naming hint for method names 207 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 208 | 209 | # Regular expression which should only match function or class names that do 210 | # not require a docstring. 211 | no-docstring-rgx=__.*__ 212 | 213 | # Minimum line length for functions/classes that require docstrings, shorter 214 | # ones are exempt. 215 | docstring-min-length=-1 216 | 217 | 218 | [TYPECHECK] 219 | 220 | # Tells whether missing members accessed in mixin class should be ignored. A 221 | # mixin class is detected if its name ends with "mixin" (case insensitive). 222 | ignore-mixin-members=yes 223 | 224 | # List of module names for which member attributes should not be checked 225 | # (useful for modules/projects where namespaces are manipulated during runtime 226 | # and thus existing member attributes cannot be deduced by static analysis 227 | ignored-modules= 228 | 229 | # List of classes names for which member attributes should not be checked 230 | # (useful for classes with attributes dynamically set). 231 | ignored-classes=SQLObject 232 | 233 | # When zope mode is activated, add a predefined set of Zope acquired attributes 234 | # to generated-members. 235 | zope=no 236 | 237 | # List of members which are set dynamically and missed by pylint inference 238 | # system, and so shouldn't trigger E0201 when accessed. Python regular 239 | # expressions are accepted. 240 | generated-members=REQUEST,acl_users,aq_parent 241 | 242 | 243 | [VARIABLES] 244 | 245 | # Tells whether we should check for unused import in __init__ files. 246 | init-import=no 247 | 248 | # A regular expression matching the name of dummy variables (i.e. expectedly 249 | # not used). 250 | dummy-variables-rgx=_$|dummy 251 | 252 | # List of additional names supposed to be defined in builtins. Remember that 253 | # you should avoid to define new builtins when possible. 254 | additional-builtins= 255 | 256 | 257 | [DESIGN] 258 | 259 | # Maximum number of arguments for function / method 260 | max-args=5 261 | 262 | # Argument names that match this expression will be ignored. Default to name 263 | # with leading underscore 264 | ignored-argument-names=_.* 265 | 266 | # Maximum number of locals for function / method body 267 | max-locals=15 268 | 269 | # Maximum number of return / yield for function / method body 270 | max-returns=6 271 | 272 | # Maximum number of branch for function / method body 273 | max-branches=12 274 | 275 | # Maximum number of statements in function / method body 276 | max-statements=50 277 | 278 | # Maximum number of parents for a class (see R0901). 279 | max-parents=7 280 | 281 | # Maximum number of attributes for a class (see R0902). 282 | max-attributes=7 283 | 284 | # Minimum number of public methods for a class (see R0903). 285 | min-public-methods=2 286 | 287 | # Maximum number of public methods for a class (see R0904). 288 | max-public-methods=20 289 | 290 | 291 | [CLASSES] 292 | 293 | # List of interface methods to ignore, separated by a comma. This is used for 294 | # instance to not check methods defines in Zope's Interface base class. 295 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 296 | 297 | # List of method names used to declare (i.e. assign) instance attributes. 298 | defining-attr-methods=__init__,__new__,setUp 299 | 300 | # List of valid names for the first argument in a class method. 301 | valid-classmethod-first-arg=cls 302 | 303 | # List of valid names for the first argument in a metaclass class method. 304 | valid-metaclass-classmethod-first-arg=mcs 305 | 306 | 307 | [IMPORTS] 308 | 309 | # Deprecated modules which should not be used, separated by a comma 310 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 311 | 312 | # Create a graph of every (i.e. internal and external) dependencies in the 313 | # given file (report RP0402 must not be disabled) 314 | import-graph= 315 | 316 | # Create a graph of external dependencies in the given file (report RP0402 must 317 | # not be disabled) 318 | ext-import-graph= 319 | 320 | # Create a graph of internal dependencies in the given file (report RP0402 must 321 | # not be disabled) 322 | int-import-graph= 323 | 324 | 325 | [EXCEPTIONS] 326 | 327 | # Exceptions that will emit a warning when being caught. Defaults to 328 | # "Exception" 329 | overgeneral-exceptions=Exception 330 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.4"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "modoboa-contacts" 7 | dynamic = [ 8 | "version", 9 | "dependencies", 10 | "optional-dependencies" 11 | ] 12 | authors = [ 13 | { name="Antoine Nguyen", email="tonio@ngyn.org" }, 14 | ] 15 | description = "Address book for Modoboa" 16 | readme = "README.rst" 17 | requires-python = ">=3.9" 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Environment :: Web Environment", 21 | "Framework :: Django :: 4.2", 22 | "Intended Audience :: System Administrators", 23 | "License :: OSI Approved :: ISC License (ISCL)", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Topic :: Communications :: Email", 31 | "Topic :: Internet :: WWW/HTTP", 32 | ] 33 | keywords = ["email"] 34 | license = {file = "LICENSE"} 35 | 36 | [project.urls] 37 | Homepage = "https://modoboa.org/" 38 | Documentation = "https://modoboa.readthedocs.io/en/latest/" 39 | Repository = "https://github.com/modoboa/modoboa-contacts" 40 | Changelog = "https://github.com/modoboa/modoboa-contacts/blob/master/CHANGELOG.md" 41 | Issues = "https://github.com/modoboa/modoboa-contacts/issues" 42 | 43 | [tool.setuptools.dynamic] 44 | version = {attr = "modoboa_contacts.version.get_version"} 45 | dependencies = {file = ["requirements.txt"]} 46 | optional-dependencies.test = { file = ["test-requirements.txt"] } 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django-webpack-loader==2.0.1 2 | vobject 3 | caldav==1.3.9 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | [pep8] 4 | max-line-length = 80 5 | exclude = migrations 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | A setuptools based setup module. 6 | 7 | See: 8 | https://packaging.python.org/en/latest/distributing.html 9 | """ 10 | 11 | from os import path 12 | 13 | from setuptools import setup, find_packages 14 | 15 | 16 | def local_scheme(version): 17 | """ 18 | Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) 19 | to be able to upload to Test PyPI 20 | """ 21 | return "" 22 | 23 | 24 | if __name__ == "__main__": 25 | HERE = path.abspath(path.dirname(__file__)) 26 | 27 | with open(path.join(HERE, "README.rst"), encoding="utf-8") as readme: 28 | LONG_DESCRIPTION = readme.read() 29 | 30 | setup( 31 | long_description=LONG_DESCRIPTION, 32 | packages=find_packages(exclude=["test_project"]), 33 | include_package_data=True, 34 | zip_safe=False, 35 | use_scm_version={"local_scheme": local_scheme}, 36 | ) 37 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | factory-boy<3.4 2 | testfixtures==8.1.0 3 | psycopg[binary]>=3.1 4 | mysqlclient<2.2.5 5 | httmock>=1.2.6 6 | -------------------------------------------------------------------------------- /test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError: 13 | # The above import may fail for some other reason. Ensure that the 14 | # issue is really that Django is missing to avoid masking other 15 | # exceptions on Python 2. 16 | try: 17 | import django # noqa 18 | except ImportError: 19 | raise ImportError( 20 | "Couldn't import Django. Are you sure it's installed and " 21 | "available on your PYTHONPATH environment variable? Did you " 22 | "forget to activate a virtual environment?" 23 | ) 24 | raise 25 | execute_from_command_line(sys.argv) 26 | -------------------------------------------------------------------------------- /test_project/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modoboa/modoboa-contacts/3620890134b1d6400a09104df83af9832d17fe8b/test_project/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.8. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | from logging.handlers import SysLogHandler 14 | import os 15 | 16 | from modoboa.test_settings import * # noqa 17 | 18 | 19 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 20 | BASE_DIR = os.path.realpath(os.path.dirname(os.path.dirname(__file__))) 21 | 22 | 23 | # Quick-start development settings - unsuitable for production 24 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 25 | 26 | # SECURITY WARNING: keep the secret key used in production secret! 27 | SECRET_KEY = 'w537@nm@5n)=+e%-7*z-jxf21a#0k%uv^rbu**+cj4=_u57e(8' 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | DEBUG = 'DEBUG' in os.environ 31 | 32 | TEMPLATE_DEBUG = DEBUG 33 | 34 | ALLOWED_HOSTS = [ 35 | '127.0.0.1', 36 | 'localhost', 37 | ] 38 | 39 | SITE_ID = 1 40 | 41 | # Security settings 42 | 43 | X_FRAME_OPTIONS = "SAMEORIGIN" 44 | 45 | # Application definition 46 | 47 | INSTALLED_APPS = ( 48 | 'django.contrib.auth', 49 | 'django.contrib.contenttypes', 50 | 'django.contrib.sessions', 51 | 'django.contrib.messages', 52 | 'django.contrib.sites', 53 | 'django.contrib.staticfiles', 54 | 'reversion', 55 | 'ckeditor', 56 | 'ckeditor_uploader', 57 | 'oauth2_provider', 58 | 'corsheaders', 59 | 'rest_framework', 60 | 'rest_framework.authtoken', 61 | 'django_otp', 62 | 'django_otp.plugins.otp_totp', 63 | 'django_otp.plugins.otp_static', 64 | ) 65 | 66 | # A dedicated place to register Modoboa applications 67 | # Do not delete it. 68 | # Do not change the order. 69 | MODOBOA_APPS = ( 70 | 'modoboa', 71 | 'modoboa.core', 72 | 'modoboa.lib', 73 | 'modoboa.admin', 74 | 'modoboa.transport', 75 | 'modoboa.relaydomains', 76 | 'modoboa.limits', 77 | 'modoboa.parameters', 78 | # Modoboa extensions here. 79 | 'modoboa_contacts', 80 | ) 81 | 82 | INSTALLED_APPS += MODOBOA_APPS 83 | 84 | AUTH_USER_MODEL = 'core.User' 85 | 86 | MIDDLEWARE = ( 87 | 'x_forwarded_for.middleware.XForwardedForMiddleware', 88 | 'django.contrib.sessions.middleware.SessionMiddleware', 89 | "corsheaders.middleware.CorsMiddleware", 90 | 'django.middleware.common.CommonMiddleware', 91 | 'django.middleware.csrf.CsrfViewMiddleware', 92 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 93 | 'django.contrib.messages.middleware.MessageMiddleware', 94 | 'django.middleware.locale.LocaleMiddleware', 95 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 96 | 'modoboa.core.middleware.LocalConfigMiddleware', 97 | 'modoboa.lib.middleware.AjaxLoginRedirect', 98 | 'modoboa.lib.middleware.CommonExceptionCatcher', 99 | 'modoboa.lib.middleware.RequestCatcherMiddleware', 100 | ) 101 | 102 | AUTHENTICATION_BACKENDS = ( 103 | # 'modoboa.lib.authbackends.LDAPBackend', 104 | # 'modoboa.lib.authbackends.SMTPBackend', 105 | 'django.contrib.auth.backends.ModelBackend', 106 | ) 107 | 108 | # SMTP authentication 109 | # AUTH_SMTP_SERVER_ADDRESS = 'localhost' 110 | # AUTH_SMTP_SERVER_PORT = 25 111 | # AUTH_SMTP_SECURED_MODE = None # 'ssl' or 'starttls' are accepted 112 | 113 | 114 | TEMPLATES = [ 115 | { 116 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 117 | 'DIRS': [], 118 | 'APP_DIRS': True, 119 | 'OPTIONS': { 120 | 'context_processors': [ 121 | 'django.template.context_processors.debug', 122 | 'django.template.context_processors.request', 123 | 'django.contrib.auth.context_processors.auth', 124 | 'django.template.context_processors.i18n', 125 | 'django.template.context_processors.media', 126 | 'django.template.context_processors.static', 127 | 'django.template.context_processors.tz', 128 | 'django.contrib.messages.context_processors.messages', 129 | 'modoboa.core.context_processors.top_notifications', 130 | ], 131 | 'debug': TEMPLATE_DEBUG, 132 | }, 133 | }, 134 | ] 135 | 136 | ROOT_URLCONF = 'test_project.urls' 137 | 138 | WSGI_APPLICATION = 'test_project.wsgi.application' 139 | 140 | 141 | # Internationalization 142 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 143 | 144 | LANGUAGE_CODE = 'en-us' 145 | 146 | TIME_ZONE = 'UTC' 147 | 148 | USE_I18N = True 149 | 150 | USE_L10N = True 151 | 152 | USE_TZ = True 153 | 154 | # Default primary key field type 155 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 156 | 157 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 158 | 159 | # Static files (CSS, JavaScript, Images) 160 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 161 | 162 | STATIC_URL = '/sitestatic/' 163 | STATIC_ROOT = os.path.join(BASE_DIR, 'sitestatic') 164 | STATICFILES_DIRS = ( 165 | # os.path.join(BASE_DIR, '..', 'modoboa', 'bower_components'), 166 | ) 167 | 168 | MEDIA_URL = '/media/' 169 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 170 | 171 | # Rest framework settings 172 | 173 | REST_FRAMEWORK = { 174 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 175 | 'rest_framework.authentication.TokenAuthentication', 176 | 'rest_framework.authentication.SessionAuthentication', 177 | ), 178 | } 179 | 180 | # Modoboa settings 181 | # MODOBOA_CUSTOM_LOGO = os.path.join(MEDIA_URL, "custom_logo.png") 182 | 183 | # DOVECOT_LOOKUP_PATH = ('/path/to/dovecot', ) 184 | 185 | MODOBOA_API_URL = 'https://api.modoboa.org/1/' 186 | 187 | # Password validation 188 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 189 | 190 | AUTH_PASSWORD_VALIDATORS = [ 191 | { 192 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 193 | }, 194 | { 195 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 196 | }, 197 | { 198 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 199 | }, 200 | { 201 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 202 | }, 203 | { 204 | 'NAME': 'modoboa.core.password_validation.ComplexityValidator', 205 | 'OPTIONS': { 206 | 'upper': 1, 207 | 'lower': 1, 208 | 'digits': 1, 209 | 'specials': 0 210 | } 211 | }, 212 | ] 213 | 214 | # CKeditor 215 | 216 | CKEDITOR_UPLOAD_PATH = "uploads/" 217 | 218 | CKEDITOR_IMAGE_BACKEND = "pillow" 219 | 220 | CKEDITOR_RESTRICT_BY_USER = True 221 | 222 | CKEDITOR_BROWSE_SHOW_DIRS = True 223 | 224 | CKEDITOR_ALLOW_NONIMAGE_FILES = False 225 | 226 | CKEDITOR_CONFIGS = { 227 | 'default': { 228 | 'allowedContent': True, 229 | 'toolbar': 'Modoboa', 230 | 'width': None, 231 | 'toolbar_Modoboa': [ 232 | ['Bold', 'Italic', 'Underline'], 233 | ['JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'], 234 | ['BidiLtr', 'BidiRtl', 'Language'], 235 | ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent'], 236 | ['Undo', 'Redo'], 237 | ['Link', 'Unlink', 'Anchor', '-', 'Smiley'], 238 | ['TextColor', 'BGColor', '-', 'Source'], 239 | ['Font', 'FontSize'], 240 | ['Image', ], 241 | ['SpellChecker'] 242 | ], 243 | }, 244 | } 245 | 246 | # Logging configuration 247 | 248 | LOGGING = { 249 | 'version': 1, 250 | 'formatters': { 251 | 'syslog': { 252 | 'format': '%(name)s: %(levelname)s %(message)s' 253 | }, 254 | }, 255 | 'handlers': { 256 | 'syslog-auth': { 257 | 'class': 'logging.handlers.SysLogHandler', 258 | 'facility': SysLogHandler.LOG_AUTH, 259 | 'formatter': 'syslog' 260 | }, 261 | 'modoboa': { 262 | 'class': 'modoboa.core.loggers.SQLHandler', 263 | } 264 | }, 265 | 'loggers': { 266 | 'modoboa.auth': { 267 | 'handlers': ['syslog-auth', 'modoboa'], 268 | 'level': 'INFO', 269 | 'propagate': False 270 | }, 271 | 'modoboa.admin': { 272 | 'handlers': ['modoboa'], 273 | 'level': 'INFO', 274 | 'propagate': False 275 | } 276 | } 277 | } 278 | 279 | # Load settings from extensions 280 | try: 281 | from modoboa_contacts import settings as modoboa_contacts_settings 282 | modoboa_contacts_settings.apply(globals()) 283 | except AttributeError: 284 | from modoboa_contacts.settings import * # noqa 285 | -------------------------------------------------------------------------------- /test_project/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf.urls import include 4 | from django.urls import re_path 5 | 6 | urlpatterns = [ 7 | re_path(r"", include("modoboa.urls")), 8 | ] 9 | -------------------------------------------------------------------------------- /test_project/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | from __future__ import unicode_literals 11 | 12 | import os 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 17 | 18 | application = get_wsgi_application() 19 | --------------------------------------------------------------------------------