├── ansible ├── hosts ├── run.sh ├── roles │ ├── firewall │ │ ├── defaults │ │ │ └── main.yml │ │ ├── handlers │ │ │ └── main.yml │ │ ├── templates │ │ │ └── jail.local.j2 │ │ └── tasks │ │ │ └── main.yml │ ├── mysql │ │ └── tasks │ │ │ └── main.yml │ ├── nginx │ │ ├── handlers │ │ │ └── main.yml │ │ ├── files │ │ │ └── https.conf │ │ ├── vars │ │ │ └── main.yml │ │ └── tasks │ │ │ ├── main.yml │ │ │ └── ssl.yml │ ├── osmcards │ │ ├── templates │ │ │ ├── transifexrc │ │ │ └── config.py.tmpl │ │ ├── files │ │ │ ├── supervisord.conf │ │ │ ├── uwsgi.ini │ │ │ └── nginx.conf │ │ ├── handlers │ │ │ └── main.yml │ │ └── tasks │ │ │ ├── db.yml │ │ │ ├── main.yml │ │ │ ├── translations.yml │ │ │ └── nginx_uwsgi.yml │ ├── backup │ │ ├── tasks │ │ │ └── main.yml │ │ └── templates │ │ │ └── backup.j2 │ └── common │ │ └── tasks │ │ └── main.yml ├── update.sh ├── requirements.yml ├── ansible.cfg ├── install_roles.yml ├── playbook.yml └── group_vars │ └── all │ ├── vars.yml │ └── vault.yml ├── osmcards.wsgi ├── www ├── static │ ├── robots.txt │ ├── uz9osm.jpg │ ├── style.css │ └── jquery.autocomplete.min.js ├── mail.py ├── front.html ├── templates │ ├── index.html │ ├── send.html │ ├── register.html │ ├── admin.html │ ├── front.html │ ├── card.html │ ├── layout.html │ ├── settings.html │ └── profile.html ├── config_default.py ├── __init__.py ├── db.py └── crossing.py ├── babel.cfg ├── .gitignore ├── requirements.txt ├── README.md ├── .tx └── config ├── Makefile └── LICENSE /ansible/hosts: -------------------------------------------------------------------------------- 1 | [osmcards] 2 | osmcards.org 3 | -------------------------------------------------------------------------------- /osmcards.wsgi: -------------------------------------------------------------------------------- 1 | from www import create_app 2 | application = create_app() 3 | -------------------------------------------------------------------------------- /www/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: /login 3 | Disallow: /profile/ 4 | -------------------------------------------------------------------------------- /www/static/uz9osm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/osmcards/HEAD/www/static/uz9osm.jpg -------------------------------------------------------------------------------- /ansible/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ansible-playbook -i hosts playbook.yml ${1+--start-at-task "$1"} 3 | -------------------------------------------------------------------------------- /ansible/roles/firewall/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | timezone: Europe/Moscow 3 | admin_email: nobody@example.com 4 | -------------------------------------------------------------------------------- /ansible/roles/firewall/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart ufw 3 | systemd: name=ufw state=restarted 4 | -------------------------------------------------------------------------------- /ansible/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | ansible-playbook -v -i hosts playbook.yml --tags osmcards 4 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: www/**.py] 2 | [jinja2: www/templates/**.html] 3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 4 | -------------------------------------------------------------------------------- /www/mail.py: -------------------------------------------------------------------------------- 1 | from flask_mailman import Mail 2 | from flask_mailman import EmailMessage as Message 3 | 4 | mail = Mail() 5 | -------------------------------------------------------------------------------- /ansible/roles/mysql/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install mysql 3 | apt: 4 | name: mariadb-server 5 | state: present 6 | -------------------------------------------------------------------------------- /ansible/roles/nginx/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart nginx 3 | service: name=nginx state=restarted 4 | become: yes 5 | -------------------------------------------------------------------------------- /ansible/requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - src: oefenweb.swapfile 3 | - src: geerlingguy.ntp 4 | - src: geerlingguy.certbot 5 | - src: geerlingguy.mysql 6 | -------------------------------------------------------------------------------- /www/front.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

Send a Postcard

4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /ansible/ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | pipelining = True 3 | roles_path = roles.galaxy:roles 4 | vault_password_file = .vault_pass 5 | retry_files_enabled = False 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.py 2 | Pipfile.lock 3 | __pycache__/ 4 | ansible/.* 5 | ansible/roles.galaxy 6 | *.swp 7 | *.db 8 | *.pot 9 | www/translations/ 10 | venv/ 11 | logo/ 12 | -------------------------------------------------------------------------------- /ansible/roles/nginx/files/https.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | server_name _; 5 | return 301 https://$host$request_uri; 6 | } 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | peewee 3 | flask-compress 4 | ruamel-yaml 5 | authlib 6 | requests 7 | flask-wtf 8 | pymysql 9 | flask-babel 10 | flask-mailman 11 | flask-table 12 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/templates/transifexrc: -------------------------------------------------------------------------------- 1 | [https://www.transifex.com] 2 | api_hostname = https://api.transifex.com 3 | hostname = https://www.transifex.com 4 | username = api 5 | password = {{ transifex_token }} 6 | rest_hostname = https://rest.api.transifex.com 7 | token = {{ transifex_token }} 8 | 9 | -------------------------------------------------------------------------------- /ansible/roles/nginx/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | acme_challenge_type: http-01 3 | acme_directory: https://acme-v02.api.letsencrypt.org/directory 4 | acme_version: 2 5 | acme_email: ilya@zverev.info 6 | 7 | letsencrypt_dir: /etc/letsencrypt 8 | letsencrypt_account_key: /etc/letsencrypt/account/account.key 9 | domain_name: osmcards.org 10 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/files/supervisord.conf: -------------------------------------------------------------------------------- 1 | [program:osmcards] 2 | user=www-data 3 | command=/usr/bin/uwsgi_python3 --ini /opt/src/uwsgi.ini 4 | stopsignal=QUIT 5 | stdout_logfile = /var/log/supervisor/osmcards-stdout.log 6 | stdout_logfile_backups = 5 7 | stderr_logfile = /var/log/supervisor/osmcards-error.log 8 | stderr_logfile_backups = 5 9 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/files/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | socket = /run/uwsgi/uwsgi.sock 3 | chmod-socket = 775 4 | chown-socket = www-data:www-data 5 | 6 | master = true 7 | gid = www-data 8 | uid = www-data 9 | processes = 2 10 | threads = 4 11 | 12 | chdir = /opt/src/osmcards 13 | virtualenv = /opt/src/osmcards/venv 14 | module = www:create_app() 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OSM Cards 2 | 3 | This is like a PostCrossing for OpenStreetMap members, but encouraging conversation. 4 | 5 | ## Translating 6 | 7 | Please translate the project [at Transifex](https://www.transifex.com/openstreetmap/osm-cards/dashboard/). 8 | 9 | ## Author and License 10 | 11 | Written by Ilya Zverev, published under MIT License. 12 | -------------------------------------------------------------------------------- /ansible/install_roles.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: localhost 3 | vars: 4 | galaxy_path: roles.galaxy 5 | tasks: 6 | - name: Remove old galaxy roles 7 | file: path={{ galaxy_path }} state=absent 8 | - name: Install Ansible Galaxy roles 9 | local_action: command ansible-galaxy install -r requirements.yml --roles-path {{ galaxy_path }} 10 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart nginx 3 | become: yes 4 | systemd: name=nginx state=restarted 5 | 6 | - name: restart supervisord 7 | become: yes 8 | systemd: name=supervisor state=restarted 9 | 10 | - name: restart uwsgi 11 | become: yes 12 | supervisorctl: 13 | name: osmcards 14 | state: restarted 15 | -------------------------------------------------------------------------------- /www/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 |
5 |
{{ _("Login with OpenStreetMap") }}
6 |
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [o:openstreetmap:p:osm-cards:r:website] 5 | file_filter = www/translations//LC_MESSAGES/messages.po 6 | source_file = messages.pot 7 | source_lang = en 8 | type = PO 9 | minimum_perc = 50 10 | replace_edited_strings = false 11 | keep_translations = false 12 | 13 | -------------------------------------------------------------------------------- /ansible/roles/firewall/templates/jail.local.j2: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | destemail = {{ admin_email }} 3 | 4 | [sshd] 5 | enabled = true 6 | maxretry = 3 7 | 8 | [apache-auth] 9 | enabled = true 10 | 11 | [apache-badbots] 12 | enabled = true 13 | 14 | [apache-overflows] 15 | enabled = true 16 | 17 | [apache-fakegooglebot] 18 | enabled = true 19 | 20 | [apache-shellshock] 21 | enabled = true 22 | 23 | [postfix] 24 | enabled = false 25 | 26 | [sendmail-auth] 27 | enabled = false 28 | -------------------------------------------------------------------------------- /www/templates/send.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ _("Send a Postcard") }} —{% endblock %} 3 | {% block content %} 4 |

{{ _("You will be given a profile with an address. Please send them a postcard.") }}

5 |
6 | 7 | 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /www/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | font-size: 100%; 4 | line-height: 1.5; 5 | } 6 | 7 | .navbar-brand { 8 | font-weight: bold; 9 | font-family: "PT Sans Caption", sans-serif; 10 | } 11 | 12 | .container-md a { 13 | text-decoration: underline; 14 | } 15 | 16 | .container-md a.btn { 17 | text-decoration: none; 18 | } 19 | 20 | .pre-wrap { 21 | white-space: pre-wrap; 22 | } 23 | 24 | #login-panel { text-align: center; margin: 2em 0 2em; } 25 | -------------------------------------------------------------------------------- /ansible/roles/nginx/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install nginx and uwsgi 3 | apt: 4 | name: uwsgi,uwsgi-plugin-python3,nginx-full,git 5 | state: present 6 | 7 | - name: Redirect HTTPS 8 | copy: 9 | src: https.conf 10 | dest: /etc/nginx/sites-available/https.conf 11 | 12 | - name: Enable redirecting HTTPS 13 | file: 14 | state: link 15 | src: /etc/nginx/sites-available/https.conf 16 | path: /etc/nginx/sites-enabled/https.conf 17 | notify: restart nginx 18 | -------------------------------------------------------------------------------- /www/config_default.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = False 4 | 5 | BASE_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), '..') 6 | DATABASE = 'sqlite:///' + os.path.join(BASE_DIR, 'osmcards.db') 7 | BASE_URL = 'http://localhost:5000' 8 | ADMINS = [1] 9 | 10 | OPENSTREETMAP_CLIENT_ID = '' 11 | OPENSTREETMAP_CLIENT_SECRET = '' 12 | SECRET_KEY = 'sdkdfsdf213fhsfljhsadf' 13 | 14 | REPLY_TO = 'osmcrossing@localhost' 15 | MAIL_FROM = 'osmcrossing@localhost' 16 | MAIL_SERVER = '' 17 | MAIL_PORT = 465 18 | MAIL_USE_SSL = True 19 | MAIL_USERNAME = '' 20 | MAIL_PASSWORD = '' 21 | -------------------------------------------------------------------------------- /ansible/roles/backup/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install Borg Backup 3 | apt: name=borgbackup 4 | 5 | - name: Create a private key 6 | copy: 7 | content: "{{ borg_key }}" 8 | dest: /root/.ssh/borg 9 | mode: 0600 10 | 11 | - name: Add rsync.net to authorized keys 12 | known_hosts: 13 | host: ch-s012.rsync.net 14 | key: 'ch-s012.rsync.net,82.197.184.220 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO5lfML3qjBiDXi4yh3xPoXPHqIOeLNp66P3Unrl+8g3' 15 | 16 | - name: Install backup script 17 | template: 18 | src: backup.j2 19 | dest: /etc/cron.daily/backup 20 | mode: 0700 21 | -------------------------------------------------------------------------------- /ansible/roles/backup/templates/backup.j2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -u -e 3 | export BORG_REPO={{ borg_repo }} 4 | export BORG_PASSPHRASE='{{ borg_pass }}' 5 | export BORG_REMOTE_PATH=borg1 6 | export BORG_RSH='ssh -i /root/.ssh/borg -oBatchMode=yes' 7 | 8 | DBDUMP=/var/tmp/osmcards.sql 9 | mysqldump --user=osmcards '--password={{ mysql_osmcards_password }}' osmcards > "$DBDUMP" 10 | 11 | borg create --compression zstd,5 ::'OSMCards_{now:%Y-%m-%d_%H%M}' \ 12 | $DBDUMP \ 13 | /etc/letsencrypt 14 | 15 | borg prune --prefix 'OSMCards_' --keep-daily=7 --keep-weekly=2 --keep-monthly=3 16 | 17 | rm $DBDUMP 18 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/templates/config.py.tmpl: -------------------------------------------------------------------------------- 1 | OPENSTREETMAP_CLIENT_ID = '{{ openstreetmap_client_id }}' 2 | OPENSTREETMAP_CLIENT_SECRET = '{{ openstreetmap_client_secret }}' 3 | SECRET_KEY = '{{ osmcards_secret_key }}' 4 | DATABASE = 'mysql://osmcards:{{ mysql_osmcards_password }}@localhost:5432/osmcards?unix_socket=/var/run/mysqld/mysqld.sock' 5 | BASE_URL = 'https://osmcards.org' 6 | 7 | REPLY_TO = 'ilya@zverev.info' 8 | MAIL_FROM = 'notify@osmcards.org' 9 | MAIL_SERVER = '{{ email_server }}' 10 | MAIL_PORT = 465 11 | MAIL_USE_SSL = True 12 | MAIL_USERNAME = '{{ email_user }}' 13 | MAIL_PASSWORD = '{{ email_password }}' 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: venv 2 | FLASK_APP=www FLASK_ENV=development venv/bin/flask run 3 | 4 | venv: 5 | python3 -m venv $@ 6 | venv/bin/pip install -r requirements.txt 7 | 8 | venv-up: venv 9 | venv/bin/pip install -r requirements.txt 10 | 11 | migrate: venv 12 | FLASK_APP=www FLASK_ENV=development venv/bin/flask migrate 13 | 14 | tr-extract: venv 15 | venv/bin/pybabel extract -F babel.cfg -k _l -k _p:1c,2 -o messages.pot . 16 | tx push -s 17 | 18 | tr-update: venv 19 | tx pull -a 20 | for lang in www/translations/*; do sed -i '/^#.*fuzzy/d' $$lang/LC_MESSAGES/messages.po; done 21 | venv/bin/pybabel compile -d www/translations 22 | -------------------------------------------------------------------------------- /ansible/roles/firewall/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install UFW 3 | apt: name=ufw state=present 4 | 5 | - name: Configure ufw defaults 6 | ufw: direction={{ item.direction }} policy={{ item.policy }} 7 | with_items: 8 | - { direction: 'incoming', policy: 'deny' } 9 | - { direction: 'outgoing', policy: 'allow' } 10 | notify: restart ufw 11 | 12 | - name: Open Nginx and SSH ports 13 | ufw: 14 | port: "{{ item }}" 15 | proto: tcp 16 | rule: allow 17 | with_items: 18 | - ssh 19 | - http 20 | - https 21 | notify: restart ufw 22 | 23 | - name: Enable ufw logging 24 | ufw: logging=on 25 | notify: restart ufw 26 | 27 | - name: Enable ufw 28 | ufw: state=enabled 29 | -------------------------------------------------------------------------------- /ansible/roles/nginx/tasks/ssl.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create directories for Let's Encrypt 3 | become: yes 4 | file: 5 | path: "{{ letsencrypt_dir }}/{{ item }}" 6 | state: directory 7 | owner: root 8 | group: root 9 | mode: u=rwx,g=x,o=x 10 | with_items: 11 | - account 12 | - certs 13 | - csrs 14 | - keys 15 | 16 | - name: Generate account key 17 | command: openssl genrsa 4096 -out {{ letsencrypt_account_key }} 18 | args: 19 | creates: "{{ letsencrypt_account_key }}" 20 | 21 | - name: Generate domain key 22 | command: openssl genrsa 4096 -out {{ letsencrypt_dir }}/keys/{{ domain_name }}.key 23 | args: 24 | creates: "{{ letsencrypt_dir }}/keys/{{ domain_name }}.key" 25 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/tasks/db.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create MySQL database 3 | become: yes 4 | mysql_db: 5 | login_unix_socket: /var/run/mysqld/mysqld.sock 6 | name: osmcards 7 | encoding: utf8 8 | state: present 9 | 10 | - name: Create MySQL user 11 | become: yes 12 | mysql_user: 13 | login_unix_socket: /var/run/mysqld/mysqld.sock 14 | host: localhost 15 | name: osmcards 16 | password: "{{ mysql_osmcards_password }}" 17 | priv: osmcards.*:ALL 18 | state: present 19 | 20 | - name: Migrate the database 21 | environment: 22 | FLASK_APP: /opt/src/osmcards/www 23 | command: /opt/src/osmcards/venv/bin/flask migrate 24 | register: db_migrate 25 | changed_when: db_migrate.stdout 26 | tags: osmcards 27 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/files/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2 default_server; 3 | ssl_certificate /etc/letsencrypt/live/osmcards.org/fullchain.pem; 4 | ssl_certificate_key /etc/letsencrypt/live/osmcards.org/privkey.pem; 5 | ssl_session_cache shared:SSL:20m; 6 | ssl_session_timeout 60m; 7 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 8 | add_header Strict-Transport-Security "max-age=31536000" always; 9 | 10 | server_name osmcards.org; 11 | 12 | location / { 13 | include uwsgi_params; 14 | uwsgi_pass unix:/run/uwsgi/uwsgi.sock; 15 | } 16 | 17 | location /static { 18 | alias /opt/src/osmcards/www/static; 19 | } 20 | 21 | location /favicon.ico { 22 | alias /opt/src/osmcards/www/static/favicon.ico; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ansible/playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: all 3 | gather_facts: no 4 | become: yes 5 | remote_user: root 6 | roles: 7 | - common 8 | 9 | - hosts: all 10 | become: yes 11 | remote_user: root 12 | roles: 13 | - role: oefenweb.swapfile 14 | swapfile_size: 1GB 15 | - geerlingguy.ntp 16 | - geerlingguy.mysql 17 | - backup 18 | - { role: geerlingguy.certbot, tags: certbot } 19 | - nginx 20 | - firewall 21 | 22 | - hosts: all 23 | remote_user: zverik 24 | vars: 25 | ansible_ssh_private_key_file: "{{ lookup('env', 'HOME') }}/.ssh/id_rsa" 26 | roles: 27 | - { role: osmcards, tags: osmcards } 28 | 29 | - hosts: localhost 30 | gather_facts: no 31 | tasks: 32 | - name: Test connection to OSM Cards 33 | uri: 34 | url: https://osmcards.org/ 35 | status_code: 200 36 | tags: osmcards 37 | -------------------------------------------------------------------------------- /www/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ _("Register a Postcard") }} —{% endblock %} 3 | {% block content %} 4 |
5 | {{ form.csrf_token }} 6 |
7 | {{ form.code.label }} 8 | {{ form.code(class='form-control' + (' is-invalid' if form.code.errors else '')) }} 9 | {% if form.code.errors %} 10 | {% for error in form.code.errors %} 11 |
{{ error }}
12 | {% endfor %} 13 | {% endif %} 14 |
15 |
16 | {{ form.comment.label }} 17 | {{ form.comment(class='form-control', rows=8) }} 18 |
19 |
20 | 21 |
22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Checkout osmcards 3 | git: 4 | repo: https://github.com/Zverik/osmcards.git 5 | dest: /opt/src/osmcards 6 | notify: restart uwsgi 7 | 8 | - name: Upload osmcards config 9 | template: 10 | src: config.py.tmpl 11 | dest: /opt/src/osmcards/config.py 12 | 13 | - name: Install virtualenv 14 | become: yes 15 | apt: 16 | name: 17 | - python3-virtualenv 18 | - python3-dev 19 | state: present 20 | 21 | - name: Create virtualenv 22 | pip: 23 | requirements: /opt/src/osmcards/requirements.txt 24 | virtualenv: /opt/src/osmcards/venv 25 | virtualenv_python: python3.8 26 | state: present 27 | 28 | - name: Init the database 29 | import_tasks: db.yml 30 | 31 | - name: Set up translations 32 | import_tasks: translations.yml 33 | 34 | - name: Set up nginx and uwsgi 35 | import_tasks: nginx_uwsgi.yml 36 | -------------------------------------------------------------------------------- /www/__init__.py: -------------------------------------------------------------------------------- 1 | from . import db 2 | from . import crossing 3 | from .mail import mail 4 | from flask import Flask, request 5 | from flask_compress import Compress 6 | from flask_wtf.csrf import CSRFProtect 7 | from flask_babel import Babel 8 | 9 | 10 | # TODO: make a function to find these 11 | SUPPORTED_LOCALES = ['en', 'ru'] 12 | 13 | 14 | def create_app(test_config=None): 15 | app = Flask(__name__) 16 | app.config.from_object('www.config_default') 17 | 18 | try: 19 | app.config.from_object('config') 20 | except FileNotFoundError: 21 | raise 22 | pass 23 | db.init_app(app) 24 | app.cli.add_command(db.migrate) 25 | crossing.oauth.init_app(app) 26 | CSRFProtect(app) 27 | babel = Babel(app) 28 | Compress(app) 29 | mail.init_app(app) 30 | 31 | def get_locale(): 32 | return request.accept_languages.best_match(SUPPORTED_LOCALES) 33 | 34 | if babel.locale_selector_func is None: 35 | babel.locale_selector_func = get_locale 36 | 37 | app.register_blueprint(crossing.cross) 38 | return app 39 | -------------------------------------------------------------------------------- /ansible/group_vars/all/vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ntp_timezone: Europe/Moscow 3 | admin_email: ilya@zverev.info 4 | 5 | borg_repo: "{{ v_borg_repo }}" 6 | borg_pass: "{{ v_borg_pass }}" 7 | borg_key: "{{ v_borg_key }}" 8 | 9 | transifex_token: "{{ v_transifex_token }}" 10 | 11 | mysql_osmcards_password: "{{ v_mysql_osmcards_password }}" 12 | osmcards_secret_key: "{{ v_osmcards_secret_key }}" 13 | openstreetmap_client_id: "{{ v_openstreetmap_client_id }}" 14 | openstreetmap_client_secret: "{{ v_openstreetmap_client_secret }}" 15 | 16 | certbot_admin_email: ilya@zverev.info 17 | certbot_create_if_missing: true 18 | certbot_create_method: standalone 19 | certbot_create_standalone_stop_services: [apache2] 20 | certbot_certs: 21 | - domains: 22 | - osmcards.org 23 | 24 | mysql_packages: 25 | - mariadb-server 26 | - mariadb-client 27 | mysql_python_package_debian: python3-mysqldb 28 | mysql_root_password: "{{ v_mariadb_root_password }}" 29 | # mysql_root_password_update: true 30 | 31 | email_server: "{{ v_email_server }}" 32 | email_user: "{{ v_email_user }}" 33 | email_password: "{{ v_email_password }}" 34 | -------------------------------------------------------------------------------- /www/templates/admin.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Statistics —{% endblock %} 3 | {% block content %} 4 | 5 | 10 | 11 | {% if panel == 'users' %} 12 | 13 | {{ users }} 14 | 15 | {% elif panel == 'interact' %} 16 | 17 |

Received Postcards

18 | {{ received }} 19 | 20 |

Active Codes

21 | {{ codes }} 22 | 23 |

Requests

24 | {{ requests }} 25 | 26 | {% elif panel == 'log' %} 27 | 28 |
29 | {% for line in log %}{{ line }}
{% endfor %} 30 |
31 | 32 | {% endif %} 33 | 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2020 by Ilya Zvere and others 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /ansible/roles/common/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Learn OS 3 | setup: 4 | gather_subset: '!all' 5 | filter: 'ansible_distribution*' 6 | 7 | - name: Test for Ubuntu 8 | when: ansible_distribution != 'Ubuntu' 9 | fail: 10 | msg: Requires Ubuntu. 11 | 12 | - name: Ensure apt cache is up to date 13 | apt: update_cache=yes cache_valid_time=3600 upgrade=dist 14 | changed_when: False 15 | 16 | - name: install setfacl support 17 | apt: name=acl 18 | 19 | - name: Create zverik user 20 | user: name=zverik shell=/bin/bash 21 | 22 | - name: Add a ssh key to zverik 23 | authorized_key: 24 | user: zverik 25 | state: present 26 | key: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/id_rsa.pub') }}" 27 | 28 | - name: Add zverik to sudoers 29 | copy: 30 | content: "zverik ALL=(ALL) NOPASSWD:ALL" 31 | dest: /etc/sudoers.d/zverik 32 | 33 | - name: Install useful packages 34 | apt: 35 | name: 36 | - tmux 37 | - htop 38 | - ncdu 39 | - vim 40 | state: present 41 | 42 | - name: Create /opt/src directory 43 | file: 44 | path: /opt/src 45 | state: directory 46 | owner: zverik 47 | mode: 0755 48 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/tasks/translations.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install transifex 3 | become: yes 4 | unarchive: 5 | src: https://github.com/transifex/cli/releases/download/v1.6.10/tx-linux-amd64.tar.gz 6 | remote_src: true 7 | dest: /usr/local/bin 8 | include: [tx] 9 | creates: /usr/local/bin/tx 10 | 11 | - name: Upload transifex configuration 12 | template: 13 | src: transifexrc 14 | dest: "/home/{{ ansible_user }}/.transifexrc" 15 | 16 | - name: Download translations 17 | command: tx pull -a 18 | args: 19 | chdir: /opt/src/osmcards 20 | register: tx_pull 21 | changed_when: "'] - Done' in tx_pull.stdout" 22 | tags: osmcards 23 | 24 | - name: Looking for po files 25 | when: tx_pull is changed 26 | find: 27 | paths: /opt/src/osmcards/www/translations 28 | patterns: messages.po 29 | file_type: file 30 | # age: 1h 31 | recurse: yes 32 | register: new_translations 33 | tags: osmcards 34 | 35 | - name: Remove fuzzy marker 36 | when: new_translations.files is not undefined 37 | replace: 38 | path: "{{ item.path }}" 39 | regexp: "^#.*fuzzy" 40 | replace: '' 41 | loop: "{{ new_translations.files }}" 42 | tags: osmcards 43 | 44 | - name: Compile messages 45 | when: new_translations.files is not undefined 46 | command: /opt/src/osmcards/venv/bin/pybabel compile -d /opt/src/osmcards/www/translations 47 | tags: osmcards 48 | notify: restart uwsgi 49 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/tasks/nginx_uwsgi.yml: -------------------------------------------------------------------------------- 1 | - name: Set up uwsgi 2 | copy: 3 | src: uwsgi.ini 4 | dest: /opt/src/uwsgi.ini 5 | notify: restart supervisord 6 | 7 | - name: Create socket directory 8 | become: yes 9 | file: 10 | path: /run/uwsgi 11 | group: www-data 12 | owner: www-data 13 | state: directory 14 | 15 | - name: Install supervisord 16 | become: yes 17 | apt: name=supervisor state=present 18 | 19 | - name: Create log directories 20 | become: yes 21 | file: 22 | path: "{{ item }}" 23 | group: www-data 24 | state: directory 25 | mode: 0755 26 | with_items: 27 | - /etc/supervisor 28 | - /etc/supervisor/conf.d 29 | - /var/log/supervisor 30 | 31 | - name: Modify supervisor configuration 32 | become: yes 33 | ini_file: dest=/etc/supervisor/supervisord.conf section=unix_http_server 34 | option={{ item.key }} value={{ item.value }} 35 | with_dict: 36 | chmod: "0770" 37 | chown: root:www-data 38 | 39 | - name: Upload supervisor configuration to web server home 40 | become: yes 41 | copy: 42 | src: supervisord.conf 43 | dest: /etc/supervisor/conf.d/osmcards.conf 44 | notify: restart supervisord 45 | 46 | - name: Upload nginx site config 47 | become: yes 48 | copy: 49 | src: nginx.conf 50 | dest: /etc/nginx/sites-available/osmcards.conf 51 | notify: restart nginx 52 | 53 | - name: Enable osmcards configuration 54 | become: yes 55 | file: 56 | state: link 57 | src: /etc/nginx/sites-available/osmcards.conf 58 | dest: /etc/nginx/sites-enabled/osmcards.conf 59 | 60 | - name: Disable default configuration 61 | become: yes 62 | file: 63 | path: /etc/nginx/sites-enabled/default 64 | state: absent 65 | notify: restart nginx 66 | -------------------------------------------------------------------------------- /www/templates/front.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

{{ _('Welcome to OSM Cards! Here we exchange postcards and emotions. To connect ' 4 | 'to another member of the OpenStreetMap community, just click this button') }}:

5 |

{{ _('Send a Postcard') }}

6 |

{{ _('Register a Postcard') }}

7 | 8 | {% if requests %} 9 |

{{ _("Postcard requests") }}: 10 | {% for req in requests %} 11 | {{ req.requested_by.name }}{{ ',' if not loop.last }} 12 | {% endfor %} 13 |

14 | {% endif %} 15 | 16 | {% if addr_requests %} 17 |

{{ _("Address reveal requests") }}: 18 | {% for req in addr_requests %} 19 | {{ req.requested_by.name }}{{ ',' if not loop.last }} 20 | {% endfor %} 21 |

22 | {% endif %} 23 | 24 | {% if mailcodes %} 25 |

{{ _("Postcards to send") }}: 26 | {% for code in mailcodes %} 27 | {{ code.lcode }}{{ ',' if not loop.last }} 28 | {% endfor %} 29 |

30 | {% endif %} 31 | 32 | {% if sent_cards %} 33 |

{{ _("Travelling postcards") }}: 34 | {% for code in sent_cards %} 35 | {{ code.lcode }}{{ ',' if not loop.last }} 36 | {% endfor %} 37 |

38 | {% endif %} 39 | 40 | {% if delivered_cards %} 41 |

{{ _("Recently delivered postcards") }}:

42 | 47 | {% endif %} 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /www/templates/card.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ _("Postcard") }} {{ code.lcode }} —{% endblock %} 3 | {% block content %} 4 | 5 |

{{ _("Postcard") }} {{ code.lcode }}

6 | 7 | {% set user_link %} 8 | {% if can_see_profile %} 9 | {{ other_user.name }} 10 | {% else %} 11 | {{ other_user.name }} 12 | {% endif %} 13 | {% endset %} 14 | 15 | {% if from_me %} 16 |

{{ _("Your postcard has been received by %(user)s — thank you!", user=user_link) }}

17 | {% else %} 18 |

{{ _("Your received this postcard from %(user)s.", user=user_link) }}

19 | {% endif %} 20 | 21 |

{{ _("Sent on %(sent)s, received on %(received)s.", 22 | sent=my_format_date(code.sent_on or code.created_on), 23 | received=my_format_date(code.received_on)) }}

24 | 25 | {% set ndays %}{{ ngettext("%(num)s day", "%(num)s days", (code.received_on - (code.sent_on or code.created_on)).days) }}{% endset %} 26 |

{{ _("%(from_country)s to %(to_country)s in %(days)s.", 27 | from_country=code.sent_by.country or _("Unknown country"), 28 | to_country=code.sent_to.country or _("Unknown country"), days=ndays) }}

29 | 30 | {% if code.comment %} 31 | {% if from_me %} 32 |

{{ _("%(user)s left a reply", user=code.sent_to.name) }}:

33 | {% else %} 34 |

{{ _("You left a reply") }}:

35 | {% endif %} 36 |

{{ code.comment }}

37 | {% elif not from_me and not code.comment %} 38 |
39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 |
48 | {% endif %} 49 | 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /www/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} OSM Cards 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% block header %}{% endblock %} 14 | 15 | 16 | 43 | 44 |
45 | {% with messages = get_flashed_messages(True) %} 46 | {% if messages %} 47 | {% for message in messages %} 48 | {% if message[0] == 'info' %} 49 |
{{ message[1] }}
50 | {% else %} 51 |
{{ message[1] }}
52 | {% endif %} 53 | {% endfor %} 54 | {% endif %} 55 | {% endwith %} 56 | {% block content %}{% endblock %} 57 |
58 | {% if g.user %} 59 |
60 | 61 | 62 |
63 | 70 | {% endif %} 71 | 72 | 73 | -------------------------------------------------------------------------------- /www/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %} 3 | {% if g.user and g.user.is_registered %} 4 | {{ g.user.name }} 5 | {% else %} 6 | Welcome 7 | {% endif %} 8 | — 9 | {% endblock %} 10 | {% block content %} 11 | 12 | {% if not g.user.is_registered %} 13 |

{{ _("Welcome to OSM Cards!") }}

14 |

{{ _("This is where you can send a postcard to any - or a specific - OpenStreetMap contributor, or receive a postcard. Make OSM feel more personal, be more connected to other mappers by exchanging hand-written cards. Share your personal code (below) to establish personal connection with somebody.") }}

15 |

{{ _("Please fill in your name and address to start using the service. By registering you consent to storing your personal data. It won't be shared with anybody except for other registered users of this website.") }}

16 | {% else %} 17 |

{{ _('Settings') }}

18 | {% endif %} 19 | 20 |

{{ _("Please use English for all fields, so that people from other countries could read your profile.") }}

21 | 22 | {% macro render_field(field) %} 23 |
24 | {{ field.label }} 25 | {{ field(class='form-control', **kwargs)|safe }} 26 | {{ field.description }} 27 | {% if field.errors %} 28 | {% for error in field.errors %} 29 |
{{ error }} 30 | {% endfor %} 31 | {% endif %} 32 |
33 | {% endmacro %} 34 | 35 |
36 | {{ form.csrf_token }} 37 |
38 |
39 | {{ render_field(form.name, autofocus='yes') }} 40 |
41 |
42 | {{ render_field(form.email) }} 43 |
44 |
45 |
46 |
47 | {{ render_field(form.address, rows=5) }} 48 |
49 |
50 | 51 | {{ _("Enter your complete address, including: your name, street address, city, postal code, and country name (on a line of its own).") }} 52 |

53 | {% set upu_link %} 54 | {{ _("UPU recommendations") }} 55 | {% endset %} 56 | {{ _("Refer to %(upu_link)s for your country to be sure.", upu_link=upu_link) }} 57 |
58 |
59 |
60 | {{ render_field(form.country) }} 61 | {{ render_field(form.languages) }} 62 | {{ render_field(form.description, rows=7) }} 63 | 64 |
65 | {{ form.does_requests(class='form-check-input')|safe }} 66 | {{ form.does_requests.label(class='form-check-label') }} 67 | {{ _("Use this URL for sharing your profile") }}: 68 | {{ url_for('c.profile', pcode=g.user.code, _external=True) }} 69 |
70 | 71 |
72 |
{{ form.privacy.label }}
73 | {% for subfield in form.privacy %} 74 |
75 | {{ subfield(class='form-check-input')|safe }} 76 | {{ subfield.label(class='form-check-label') }} 77 | {% if subfield.data == 4 %} 78 | {{ ngettext("There is %(num)s confirmed user", "There are %(num)s confirmed users", count_confirmed) }} 79 | {% endif %} 80 |
81 | {% endfor %} 82 |
83 | 84 |

85 |

86 |
87 | 88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /www/templates/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {# 3 | So people get here in three ways: 4 | - At random after clicking "Send a Postcard" or from code history. 5 | from_mailcode == True 6 | - They are looking at their own profile. 7 | me == True 8 | - Via a link to the profile somewhere. 9 | not mailcode and not me 10 | - After registering their postcard. 11 | recent_card == True 12 | 13 | Also, when there's an active mailcode, "code" 14 | is set. Check for code.sent_on to know if it's been sent. 15 | #} 16 | {% block title %}{{ user.name }} —{% endblock %} 17 | {% block content %} 18 | 19 | {% if not code %} 20 |

{{ user.name }}

21 | {% elif code.received_on %} 22 |

{{ user.name }}

23 |

{{ _("Your postcard has been received — thank you!") }}

24 | {% else %} 25 |

{{ _("Send postcard #%(code)s to %(user)s", code=code.lcode, user=user.name) }}

26 | {% if code.sent_on %} 27 |

{{ _("You have marked that you have sent the postcard to this address") }}:

28 | {% else %} 29 |

{{ _('You should mail it to this address — do not forget to write the code "%(code)s" on it!', code=code.lcode) }}

30 | {% endif %} 31 | {% endif %} 32 | 33 | {% if asked_for_address %} 34 |

Show Them My Address

35 | {% endif %} 36 | 37 | {% if (code and not code.received_on and can_see_address) or me %} 38 | {% if me %} 39 |

{{ _("This is your address. Edit it if it's obsolete or wrong.", url=url_for('c.user')) }}

40 | {% endif %} 41 |

{{ user.address }}

42 |

{{ _("Country") }}: {{ user.country }}.

43 | {% endif %} 44 | 45 | {% if user.languages %} 46 |

{{ _("Languages they understand: %(lang)s", lang=user.languages) }}.

47 | {% endif %} 48 | 49 | {% if user.description %} 50 |

{{ _("Few words about %(user)s", user=user.name) }}:

51 |

{{ user.description }}

52 | {% endif %} 53 | 54 | {% if recent_card and not recent_card.comment %} 55 |
56 |
57 |
58 | 59 | 60 |
61 |
62 | 63 |
64 | {% endif %} 65 | 66 | {% if can_send %} 67 | {% if they_requested %} 68 |

{{ _("They requested a postcard from you. Please click this button and send one") }}:

69 | {% endif %} 70 |
71 | 72 | 73 | 74 |
75 | {% endif %} 76 | 77 | {% if can_ask %} 78 |
79 | 80 | 81 | 82 |
83 | {% endif %} 84 | 85 | {% if req %} 86 |

{{ _("You have requested a postcard from them.") }}

87 | {% elif can_request %} 88 |
89 | 90 | 91 | 92 |
93 | {% endif %} 94 | 95 | {% if code and not code.received_on %} 96 | 97 | {% if not code.sent_on %}{{ _("Mark as sent") }}{% else %}{{ _("Mark as not sent") }}{% endif %} 98 | 99 | {% endif %} 100 | 101 | {% endblock %} 102 | -------------------------------------------------------------------------------- /www/db.py: -------------------------------------------------------------------------------- 1 | import click 2 | from enum import IntEnum 3 | from datetime import datetime 4 | from flask.cli import with_appcontext, current_app 5 | from playhouse.db_url import connect 6 | from playhouse.migrate import ( 7 | migrate as peewee_migrate, 8 | SqliteMigrator, 9 | MySQLMigrator, 10 | PostgresqlMigrator 11 | ) 12 | from peewee import ( 13 | fn, 14 | DatabaseProxy, 15 | Model, 16 | CharField, 17 | TextField, 18 | IntegerField, 19 | ForeignKeyField, 20 | BooleanField, 21 | DateTimeField 22 | ) 23 | 24 | database = DatabaseProxy() 25 | 26 | 27 | CODE_LETTERS = 'ABCFJKMNPT' 28 | 29 | 30 | def set_up_logging(): 31 | import logging 32 | logger = logging.getLogger('peewee') 33 | logger.addHandler(logging.StreamHandler()) 34 | logger.setLevel(logging.DEBUG) 35 | 36 | 37 | def init_app(app): 38 | def open_db(): 39 | database.connect() 40 | 41 | def close_db(exception): 42 | if not database.is_closed(): 43 | database.close() 44 | 45 | # set_up_logging() 46 | new_db = connect(app.config['DATABASE']) 47 | database.initialize(new_db) 48 | app.before_request(open_db) 49 | app.teardown_request(close_db) 50 | 51 | 52 | def fn_Random(): 53 | if 'mysql' in current_app.config['DATABASE']: 54 | return fn.Rand() 55 | else: 56 | return fn.Random() 57 | 58 | 59 | class AddressPrivacy(IntEnum): 60 | OPEN = 2 # Any user can get this user's data 61 | CONFIRMED = 4 # Visible to only confirmed users 62 | PROFILE = 6 # Does not participate in random 63 | ASK = 8 # Ask for permission, not in random 64 | CLOSED = 10 # Does not appear anywhere 65 | 66 | 67 | class BaseModel(Model): 68 | class Meta: 69 | database = database 70 | 71 | 72 | class User(BaseModel): 73 | created_on = DateTimeField(default=datetime.now) 74 | active_on = DateTimeField(default=datetime.now) 75 | name = CharField(max_length=250) 76 | code = CharField(max_length=32, index=True) # Secret code to request a postcard 77 | osm_uid = IntegerField(index=True) 78 | osm_name = CharField() 79 | email = CharField(max_length=250, null=True) 80 | description = TextField(null=True) # "About", like on postcrossing. No links. 81 | languages = CharField(default='English') # Plain text 82 | site_lang = CharField(max_length=7, default='en') 83 | address = TextField(null=True) # Properly formatted, with newlines 84 | is_active = BooleanField(default=True) # False if not visible. Kind of account deletion 85 | privacy = IntegerField(default=AddressPrivacy.OPEN) 86 | does_requests = BooleanField(default=False) 87 | country = CharField(max_length=250, null=True) # TODO: free-form or string name? 88 | 89 | @property 90 | def is_registered(self): 91 | return self.name and self.address and self.is_active 92 | 93 | @property 94 | def is_admin(self): 95 | return self.id == 1 96 | 97 | 98 | class MailCode(BaseModel): 99 | created_on = DateTimeField(default=datetime.now) 100 | code = IntegerField(primary_key=True) 101 | sent_by = ForeignKeyField(User, index=True) 102 | sent_to = ForeignKeyField(User) 103 | sent_address = CharField() 104 | sent_on = DateTimeField(null=True) 105 | received_on = DateTimeField(null=True) 106 | comment = TextField(null=True) # Comment from receiver 107 | is_active = BooleanField(default=True) # Not received and not expired 108 | 109 | @property 110 | def lcode(self): 111 | """Letter code: A2345 for 12345.""" 112 | s = str(self.code) 113 | return CODE_LETTERS[int(s[0]) - 1] + s[1:] 114 | 115 | @staticmethod 116 | def restore_code(code): 117 | if not code: 118 | return None 119 | if isinstance(code, int): 120 | return code 121 | code = code.upper() 122 | cl_idx = CODE_LETTERS.find(code[0]) 123 | if cl_idx >= 0: 124 | code = str(cl_idx + 1) + code[1:] 125 | try: 126 | return int(code) 127 | except ValueError: 128 | return 0 129 | 130 | 131 | class MailRequest(BaseModel): 132 | created_on = DateTimeField(default=datetime.now) 133 | is_active = BooleanField(default=True) 134 | is_hidden = BooleanField(default=False) 135 | requested_by = ForeignKeyField(User, index=True) 136 | requested_from = ForeignKeyField(User, index=True) 137 | comment = TextField(null=True) 138 | 139 | 140 | class ProfileRequest(BaseModel): 141 | created_on = DateTimeField(default=datetime.now) 142 | requested_by = ForeignKeyField(User, index=True) 143 | requested_from = ForeignKeyField(User, index=True) 144 | granted = BooleanField(null=True) 145 | 146 | 147 | # MIGRATION ############################################# 148 | 149 | 150 | LAST_VERSION = 1 151 | 152 | 153 | class Version(BaseModel): 154 | version = IntegerField() 155 | 156 | 157 | @click.command('migrate') 158 | @with_appcontext 159 | def migrate(): 160 | database.connect() 161 | database.create_tables([Version], safe=True) 162 | try: 163 | v = Version.select().get() 164 | except Version.DoesNotExist: 165 | # Prints are here to mark a change for Ansible 166 | print('Creating tables') 167 | database.create_tables([User, MailCode, MailRequest, ProfileRequest]) 168 | v = Version(version=LAST_VERSION) 169 | v.save() 170 | 171 | if v.version >= LAST_VERSION: 172 | return 173 | 174 | print('Upgrading database version {} to version {}'.format(v.version, LAST_VERSION)) 175 | 176 | uri = current_app.config['DATABASE'] 177 | if 'mysql' in uri: 178 | migrator = MySQLMigrator(database) 179 | elif 'sqlite' in uri: 180 | migrator = SqliteMigrator(database) 181 | else: 182 | migrator = PostgresqlMigrator(database) 183 | 184 | if v.version == 0: 185 | database.create_tables([ProfileRequest]) 186 | v.version = 1 187 | v.save() 188 | 189 | # When making further migrations, refer to 190 | # https://github.com/mapsme/cf_audit/blob/master/www/db.py 191 | 192 | if v.version != LAST_VERSION: 193 | raise ValueError('LAST_VERSION in db.py should be {}'.format(v.version)) 194 | -------------------------------------------------------------------------------- /ansible/group_vars/all/vault.yml: -------------------------------------------------------------------------------- 1 | $ANSIBLE_VAULT;1.1;AES256 2 | 33623463363535653032636164653061316463323430656131633531306433333636383732376233 3 | 6138313865303161623965386661636330366633653133350a626538396164633331656632326136 4 | 35303931356438313831653739346438653864316437333433633463643266303364323032366234 5 | 6164656364643864390a623736396564323465343961346130643662626337623039613131323161 6 | 65343236323731396366383835326231316430373230626537383335396162633861393031633161 7 | 32373565656163333564643639303561373631313666363436643238666262376237626539376234 8 | 39633665373735323862653266393433353239656337396138363565383638613035313762363166 9 | 35663063336333376466353134363435656539653135383963323631613861303332623662326238 10 | 36653364323835343037663366316466393833313231303564313634306563383561376531303534 11 | 35383064653463393764666435356161356138343936666530343432303963353732346630636461 12 | 37353137373262353262323663376339386437363461653832363466336366396637613063383864 13 | 66636539306338303735663439333436656662623864663134376437656534393465303662626631 14 | 63643862613866343662393730316566343731383464303938306638616362343631623864623564 15 | 65396135333164316233303564353433616232366235326265616265383866613964326638646263 16 | 64633266323965396561356139393638393039356636393637323737643233663932353765313138 17 | 66313865366665633737656665343431303439313530353964366562343436653331396662616363 18 | 36623032373164633066353164346433643436333739646361616433323135656338363231636563 19 | 64613539383165646438663432316235666165396434343730633461346365646566643637383461 20 | 62663234646635383762623364613139336466343961643062316432373334323634653131383138 21 | 31643039636337366230613464653832313564323762333330356139326135373364613766626664 22 | 30656232666561323162326262356333366638633836393465373666303831656630356436663435 23 | 37613034373134363531393665343435666438373565366666626236373232356335643263303462 24 | 37613136343965376131386663383361376439343133373634373439396334373631373633333235 25 | 62643639343436386661343566383339633165383065343466633566656666306137326133333334 26 | 32316165356163346538633265646466643935366638333230613165373163646431396563623734 27 | 66326466356234376639646537313035376365333565326263353638633239646433306561613739 28 | 62386336643231633734613333613238363834313463323533363435356162666237303236643431 29 | 33363131623132383161343331626536393362326664343562363231613164343433646466306365 30 | 33383962353432333234643633633937333833383863366637666639366338663833363231623037 31 | 37383966386633613961613136366233323534643039636464363563396433356633313536366136 32 | 36376262366166323631643964343965383861653063366463643061653639363538363930323464 33 | 38333563353763353235326665633030353964323033393533613737376633393363343930643030 34 | 35626466643662656163663239303239353836336661343434646166386338376534613030653530 35 | 36653665343166346437613330303737383966323861313865333937663034643930333562356232 36 | 34316333636237623930396263323430663863323733386633396661663936336537346166353265 37 | 37323863393538366433353836636166633033613534616661613463633765653834396539303137 38 | 39623233666336663537366433663962633233396638633537623965623933396239326464383936 39 | 30323739306164626236363733363433326366613961356566633437313732326262306365373830 40 | 66383765663332663232343132353632376639643733333936396566343431663938386538666663 41 | 39633666333930343364373932363366333361626537323837353366663435653136346134336232 42 | 31356437633331333332393338313462363437313532646337396233643934376163663830643635 43 | 30393737333031613538316139376264376433633461383137343062363630306639643136346266 44 | 61656364343332303232313330333831373639383265356336376663336561613239626634386566 45 | 30393031623566656662626137633166633032613036366439323361653562633831373265363864 46 | 36313133626131343662633739643636366166383038643134373466326634306261383730303636 47 | 33653537626637396465633637643031633162303262316631373061616134636239376636383236 48 | 35343662353065333662636362346563313664633635396237666430666464343362363037623366 49 | 61616638333035636636663861666139653931353962343964666162633662343661363565663830 50 | 36393362626330393939363166633261393337366661633333316262643435623166386366626564 51 | 32373432373461366564663735643361636630646230663834646339356632623463323536633862 52 | 37303962343531323633363636633863313863386135303961613963336564303066613731323631 53 | 30613664653665386330393161356633376433656562343030323830636264323164353163363633 54 | 30663231653633336632316430356636666233303366303961336533313936636433343262613035 55 | 65363866393963393136343430663462323963616430346266356135396532363837616636633731 56 | 33633134393735306465623834336438633738343661363465616337333662626362393334633934 57 | 34613262303864303563383932333633363236346133613964306465326566376438323965623465 58 | 61363039306562333039303530656565346634646434646264653230373038336262333737343133 59 | 36613338626434633461616630386530656661646234396465353033633138646238313963613936 60 | 30313534303538373266363764656562646164326239666363643734646533316166336466373766 61 | 32346231616539633939316438613965353438323030653339636637356634313164353164393139 62 | 61643465323330643936326230336634646634653966663030346535613732666264363539653633 63 | 66376561613837373262353538626536613339396566666633313635393533626239353736336439 64 | 36636433386465306530386164313633396661343636313830346563366535616263373037666362 65 | 33346166313535343566643531396261663362616261386364643637353662316330323835616565 66 | 30663239313537316262343562656636353962363564636337356566393035643734633965303733 67 | 64333735643864643730626637643431646366633636373739393965306434383166666462633839 68 | 33643266343938626231323565626637383538333434323962623533616166353135313166306361 69 | 65343862353936393563666232653438306535633864653765653335633739613662666238333439 70 | 31363239643930346330333937663762366630346238653738653032626163356333306336643162 71 | 39396665326566343666303565313365646435323361386665323633646537346638363462353831 72 | 38336232623335333331323665653065613037383338613734626635316135346134393834306464 73 | 30353133396663393434323964396434636164616364316433353964326164626462303330663939 74 | 32636131386332646333326263363738323665396332326564656236366661666462366438666632 75 | 65333136396664363431326664376463353765336566393963316635623236323034616432663465 76 | 32636362653531383265633339343935643139613039353536373930616233333064316532616538 77 | 38656231626161633731303364623830313432376666616339333264363138663432346132316466 78 | 64393532353761326435353336353633303765353666643766383630656266346336346434626436 79 | 36353662393736653464363666346165613065653463306663373264373333643465653665373538 80 | 63306535623639666365663936346563326539336164313534396133323632626135396130643366 81 | 35356133343430396232303539623565653834343365393038363166663061323934616139636131 82 | 39653965393930633065656339383261366531383835393334643538336163613539666138373232 83 | 63393965646236323332643838373434323538666331363033346232366466376634356166323466 84 | 31613932666632356161363238633066386666373264613661366232613933626630656263343962 85 | 33656432303765656634316462616230333430306565386135623964653238616664333931353762 86 | 64636336383061306631306338393238626634613033306330316363363132353263376265353162 87 | 31626232383739393134633863663263633337343139356434326432323434626466613761373731 88 | 64373166386362373961393564633831306436306261653036626137633861353331613561346466 89 | 35343461633662663238313039306363653464636339306463346637306532336564313662383264 90 | 64303938356534303336643832323435323235666632646161366466656237393630393034623139 91 | 61646466316337636261356261303538326164346537366165333261326535366165393534613861 92 | 30663865306166346337616332643064353763376231643261656131653631623131336464656339 93 | 33366664316364313863653664386462396336643038396131613230653233666465343638346138 94 | 38323539636566343933393238623837653465396134383431316639383165653738353665623930 95 | 33656535346130643731663333613435616438353233643036396130386566636436636566623030 96 | 62636530393138616239376433373433356536343137653861303333663566323862386261383938 97 | 61343836623034353864343831616539396635313266343233303634616336663435666661376435 98 | 62383462663830653131303365316437343435653832616665653638633133323436663863646438 99 | 61323339643161396538633664613362333532346665646463333065613136646363623035653666 100 | 33383363646234653066613166626232656437383935663532323662356463376334633862366133 101 | 62373662333431373666613239666562363236653137643638386433623637383531623166393632 102 | 35636564306132373739316233663662346538613232666364363861313438333636646232356137 103 | 30313138666236353266313764373064646262376362376666373530373732363931653762336337 104 | 37633763663333653066336635373961623738313735613530363532646534373530623162303530 105 | 34336135613130326137373337633163313062383665393938386164323535343037323938393730 106 | 34633462343833343362333763666266623330323333376563666433383562316634316134643761 107 | 37383964623266633064666337313233383066383035346363646566653730653761616639353537 108 | 38366435653231333065353038646231323062323762613534373639386236383166653439613937 109 | 38363763613138633235613261316437373438383562636531666264653162323635616563333565 110 | 39326562653734313938353233333365386436343864353237373234386639346239656632666436 111 | 65323232303232316532323634663364356533383136303932326165303330333732306431343738 112 | 32336430646664616362366437336539383633316433393831306431633665353832396136613839 113 | 33326330336162376661303164633834396634343437633737306266653332633437386664323063 114 | 35383765613462313532366365363439333737393032373261323733373963353264306638343334 115 | 39653337663334663833333134396534353436623737363530663434666237383431643839376233 116 | 61383061353137356466643665303830663137343437623263346436656363663739306331306364 117 | 65383431636534353438363538346261656430336263613433343862326162386637376135346433 118 | 64636461336239323864643764313338333731326436393864353061386130653132363638646337 119 | 64356537313861386264363538326336363137333337353433623166323466303738616335633661 120 | 62393064613835633664643464336136326663663738643739323935376534333331663032393366 121 | 36646233326339303935656635346366656131623464383031613262363463663138313034313430 122 | 36383234633936326532656234313336353561653662353632363331663239356363303461636433 123 | 64663238343838613930363938633563326162346330613665366631313162623635633064393531 124 | 333135393833396465643731343164393837 125 | -------------------------------------------------------------------------------- /www/static/jquery.autocomplete.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Ajax Autocomplete for jQuery, version 1.4.11 3 | * (c) 2017 Tomas Kirda 4 | * 5 | * Ajax Autocomplete for jQuery is freely distributable under the terms of an MIT-style license. 6 | * For details, see the web site: https://github.com/devbridge/jQuery-Autocomplete 7 | */ 8 | !function(a){"use strict";"function"==typeof define&&define.amd?define(["jquery"],a):a("object"==typeof exports&&"function"==typeof require?require("jquery"):jQuery)}(function(a){"use strict";function b(c,d){var e=this;e.element=c,e.el=a(c),e.suggestions=[],e.badQueries=[],e.selectedIndex=-1,e.currentValue=e.element.value,e.timeoutId=null,e.cachedResponse={},e.onChangeTimeout=null,e.onChange=null,e.isLocal=!1,e.suggestionsContainer=null,e.noSuggestionsContainer=null,e.options=a.extend(!0,{},b.defaults,d),e.classes={selected:"autocomplete-selected",suggestion:"autocomplete-suggestion"},e.hint=null,e.hintValue="",e.selection=null,e.initialize(),e.setOptions(d)}function c(a,b,c){return a.value.toLowerCase().indexOf(c)!==-1}function d(b){return"string"==typeof b?a.parseJSON(b):b}function e(a,b){if(!b)return a.value;var c="("+g.escapeRegExChars(b)+")";return a.value.replace(new RegExp(c,"gi"),"$1").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/<(\/?strong)>/g,"<$1>")}function f(a,b){return'
'+b+"
"}var g=function(){return{escapeRegExChars:function(a){return a.replace(/[|\\{}()[\]^$+*?.]/g,"\\$&")},createNode:function(a){var b=document.createElement("div");return b.className=a,b.style.position="absolute",b.style.display="none",b}}}(),h={ESC:27,TAB:9,RETURN:13,LEFT:37,UP:38,RIGHT:39,DOWN:40},i=a.noop;b.utils=g,a.Autocomplete=b,b.defaults={ajaxSettings:{},autoSelectFirst:!1,appendTo:"body",serviceUrl:null,lookup:null,onSelect:null,width:"auto",minChars:1,maxHeight:300,deferRequestBy:0,params:{},formatResult:e,formatGroup:f,delimiter:null,zIndex:9999,type:"GET",noCache:!1,onSearchStart:i,onSearchComplete:i,onSearchError:i,preserveInput:!1,containerClass:"autocomplete-suggestions",tabDisabled:!1,dataType:"text",currentRequest:null,triggerSelectOnValidInput:!0,preventBadQueries:!0,lookupFilter:c,paramName:"query",transformResult:d,showNoSuggestionNotice:!1,noSuggestionNotice:"No results",orientation:"bottom",forceFixPosition:!1},b.prototype={initialize:function(){var c,d=this,e="."+d.classes.suggestion,f=d.classes.selected,g=d.options;d.element.setAttribute("autocomplete","off"),d.noSuggestionsContainer=a('
').html(this.options.noSuggestionNotice).get(0),d.suggestionsContainer=b.utils.createNode(g.containerClass),c=a(d.suggestionsContainer),c.appendTo(g.appendTo||"body"),"auto"!==g.width&&c.css("width",g.width),c.on("mouseover.autocomplete",e,function(){d.activate(a(this).data("index"))}),c.on("mouseout.autocomplete",function(){d.selectedIndex=-1,c.children("."+f).removeClass(f)}),c.on("click.autocomplete",e,function(){d.select(a(this).data("index"))}),c.on("click.autocomplete",function(){clearTimeout(d.blurTimeoutId)}),d.fixPositionCapture=function(){d.visible&&d.fixPosition()},a(window).on("resize.autocomplete",d.fixPositionCapture),d.el.on("keydown.autocomplete",function(a){d.onKeyPress(a)}),d.el.on("keyup.autocomplete",function(a){d.onKeyUp(a)}),d.el.on("blur.autocomplete",function(){d.onBlur()}),d.el.on("focus.autocomplete",function(){d.onFocus()}),d.el.on("change.autocomplete",function(a){d.onKeyUp(a)}),d.el.on("input.autocomplete",function(a){d.onKeyUp(a)})},onFocus:function(){var a=this;a.disabled||(a.fixPosition(),a.el.val().length>=a.options.minChars&&a.onValueChange())},onBlur:function(){var b=this,c=b.options,d=b.el.val(),e=b.getQuery(d);b.blurTimeoutId=setTimeout(function(){b.hide(),b.selection&&b.currentValue!==e&&(c.onInvalidateSelection||a.noop).call(b.element)},200)},abortAjax:function(){var a=this;a.currentRequest&&(a.currentRequest.abort(),a.currentRequest=null)},setOptions:function(b){var c=this,d=a.extend({},c.options,b);c.isLocal=Array.isArray(d.lookup),c.isLocal&&(d.lookup=c.verifySuggestionsFormat(d.lookup)),d.orientation=c.validateOrientation(d.orientation,"bottom"),a(c.suggestionsContainer).css({"max-height":d.maxHeight+"px",width:d.width+"px","z-index":d.zIndex}),this.options=d},clearCache:function(){this.cachedResponse={},this.badQueries=[]},clear:function(){this.clearCache(),this.currentValue="",this.suggestions=[]},disable:function(){var a=this;a.disabled=!0,clearTimeout(a.onChangeTimeout),a.abortAjax()},enable:function(){this.disabled=!1},fixPosition:function(){var b=this,c=a(b.suggestionsContainer),d=c.parent().get(0);if(d===document.body||b.options.forceFixPosition){var e=b.options.orientation,f=c.outerHeight(),g=b.el.outerHeight(),h=b.el.offset(),i={top:h.top,left:h.left};if("auto"===e){var j=a(window).height(),k=a(window).scrollTop(),l=-k+h.top-f,m=k+j-(h.top+g+f);e=Math.max(l,m)===l?"top":"bottom"}if("top"===e?i.top+=-f:i.top+=g,d!==document.body){var n,o=c.css("opacity");b.visible||c.css("opacity",0).show(),n=c.offsetParent().offset(),i.top-=n.top,i.top+=d.scrollTop,i.left-=n.left,b.visible||c.css("opacity",o).hide()}"auto"===b.options.width&&(i.width=b.el.outerWidth()+"px"),c.css(i)}},isCursorAtEnd:function(){var a,b=this,c=b.el.val().length,d=b.element.selectionStart;return"number"==typeof d?d===c:!document.selection||(a=document.selection.createRange(),a.moveStart("character",-c),c===a.text.length)},onKeyPress:function(a){var b=this;if(!b.disabled&&!b.visible&&a.which===h.DOWN&&b.currentValue)return void b.suggest();if(!b.disabled&&b.visible){switch(a.which){case h.ESC:b.el.val(b.currentValue),b.hide();break;case h.RIGHT:if(b.hint&&b.options.onHint&&b.isCursorAtEnd()){b.selectHint();break}return;case h.TAB:if(b.hint&&b.options.onHint)return void b.selectHint();if(b.selectedIndex===-1)return void b.hide();if(b.select(b.selectedIndex),b.options.tabDisabled===!1)return;break;case h.RETURN:if(b.selectedIndex===-1)return void b.hide();b.select(b.selectedIndex);break;case h.UP:b.moveUp();break;case h.DOWN:b.moveDown();break;default:return}a.stopImmediatePropagation(),a.preventDefault()}},onKeyUp:function(a){var b=this;if(!b.disabled){switch(a.which){case h.UP:case h.DOWN:return}clearTimeout(b.onChangeTimeout),b.currentValue!==b.el.val()&&(b.findBestHint(),b.options.deferRequestBy>0?b.onChangeTimeout=setTimeout(function(){b.onValueChange()},b.options.deferRequestBy):b.onValueChange())}},onValueChange:function(){if(this.ignoreValueChange)return void(this.ignoreValueChange=!1);var b=this,c=b.options,d=b.el.val(),e=b.getQuery(d);return b.selection&&b.currentValue!==e&&(b.selection=null,(c.onInvalidateSelection||a.noop).call(b.element)),clearTimeout(b.onChangeTimeout),b.currentValue=d,b.selectedIndex=-1,c.triggerSelectOnValidInput&&b.isExactMatch(e)?void b.select(0):void(e.lengthh&&(c.suggestions=c.suggestions.slice(0,h)),c},getSuggestions:function(b){var c,d,e,f,g=this,h=g.options,i=h.serviceUrl;if(h.params[h.paramName]=b,h.onSearchStart.call(g.element,h.params)!==!1){if(d=h.ignoreParams?null:h.params,a.isFunction(h.lookup))return void h.lookup(b,function(a){g.suggestions=a.suggestions,g.suggest(),h.onSearchComplete.call(g.element,b,a.suggestions)});g.isLocal?c=g.getSuggestionsLocal(b):(a.isFunction(i)&&(i=i.call(g.element,b)),e=i+"?"+a.param(d||{}),c=g.cachedResponse[e]),c&&Array.isArray(c.suggestions)?(g.suggestions=c.suggestions,g.suggest(),h.onSearchComplete.call(g.element,b,c.suggestions)):g.isBadQuery(b)?h.onSearchComplete.call(g.element,b,[]):(g.abortAjax(),f={url:i,data:d,type:h.type,dataType:h.dataType},a.extend(f,h.ajaxSettings),g.currentRequest=a.ajax(f).done(function(a){var c;g.currentRequest=null,c=h.transformResult(a,b),g.processResponse(c,b,e),h.onSearchComplete.call(g.element,b,c.suggestions)}).fail(function(a,c,d){h.onSearchError.call(g.element,b,a,c,d)}))}},isBadQuery:function(a){if(!this.options.preventBadQueries)return!1;for(var b=this.badQueries,c=b.length;c--;)if(0===a.indexOf(b[c]))return!0;return!1},hide:function(){var b=this,c=a(b.suggestionsContainer);a.isFunction(b.options.onHide)&&b.visible&&b.options.onHide.call(b.element,c),b.visible=!1,b.selectedIndex=-1,clearTimeout(b.onChangeTimeout),a(b.suggestionsContainer).hide(),b.signalHint(null)},suggest:function(){if(!this.suggestions.length)return void(this.options.showNoSuggestionNotice?this.noSuggestions():this.hide());var b,c=this,d=c.options,e=d.groupBy,f=d.formatResult,g=c.getQuery(c.currentValue),h=c.classes.suggestion,i=c.classes.selected,j=a(c.suggestionsContainer),k=a(c.noSuggestionsContainer),l=d.beforeRender,m="",n=function(a,c){var f=a.data[e];return b===f?"":(b=f,d.formatGroup(a,b))};return d.triggerSelectOnValidInput&&c.isExactMatch(g)?void c.select(0):(a.each(c.suggestions,function(a,b){e&&(m+=n(b,g,a)),m+='
'+f(b,g,a)+"
"}),this.adjustContainerWidth(),k.detach(),j.html(m),a.isFunction(l)&&l.call(c.element,j,c.suggestions),c.fixPosition(),j.show(),d.autoSelectFirst&&(c.selectedIndex=0,j.scrollTop(0),j.children("."+h).first().addClass(i)),c.visible=!0,void c.findBestHint())},noSuggestions:function(){var b=this,c=b.options.beforeRender,d=a(b.suggestionsContainer),e=a(b.noSuggestionsContainer);this.adjustContainerWidth(),e.detach(),d.empty(),d.append(e),a.isFunction(c)&&c.call(b.element,d,b.suggestions),b.fixPosition(),d.show(),b.visible=!0},adjustContainerWidth:function(){var b,c=this,d=c.options,e=a(c.suggestionsContainer);"auto"===d.width?(b=c.el.outerWidth(),e.css("width",b>0?b:300)):"flex"===d.width&&e.css("width","")},findBestHint:function(){var b=this,c=b.el.val().toLowerCase(),d=null;c&&(a.each(b.suggestions,function(a,b){var e=0===b.value.toLowerCase().indexOf(c);return e&&(d=b),!e}),b.signalHint(d))},signalHint:function(b){var c="",d=this;b&&(c=d.currentValue+b.value.substr(d.currentValue.length)),d.hintValue!==c&&(d.hintValue=c,d.hint=b,(this.options.onHint||a.noop)(c))},verifySuggestionsFormat:function(b){return b.length&&"string"==typeof b[0]?a.map(b,function(a){return{value:a,data:null}}):b},validateOrientation:function(b,c){return b=a.trim(b||"").toLowerCase(),a.inArray(b,["auto","bottom","top"])===-1&&(b=c),b},processResponse:function(a,b,c){var d=this,e=d.options;a.suggestions=d.verifySuggestionsFormat(a.suggestions),e.noCache||(d.cachedResponse[c]=a,e.preventBadQueries&&!a.suggestions.length&&d.badQueries.push(b)),b===d.getQuery(d.currentValue)&&(d.suggestions=a.suggestions,d.suggest())},activate:function(b){var c,d=this,e=d.classes.selected,f=a(d.suggestionsContainer),g=f.find("."+d.classes.suggestion);return f.find("."+e).removeClass(e),d.selectedIndex=b,d.selectedIndex!==-1&&g.length>d.selectedIndex?(c=g.get(d.selectedIndex),a(c).addClass(e),c):null},selectHint:function(){var b=this,c=a.inArray(b.hint,b.suggestions);b.select(c)},select:function(a){var b=this;b.hide(),b.onSelect(a)},moveUp:function(){var b=this;if(b.selectedIndex!==-1)return 0===b.selectedIndex?(a(b.suggestionsContainer).children("."+b.classes.suggestion).first().removeClass(b.classes.selected),b.selectedIndex=-1,b.ignoreValueChange=!1,b.el.val(b.currentValue),void b.findBestHint()):void b.adjustScroll(b.selectedIndex-1)},moveDown:function(){var a=this;a.selectedIndex!==a.suggestions.length-1&&a.adjustScroll(a.selectedIndex+1)},adjustScroll:function(b){var c=this,d=c.activate(b);if(d){var e,f,g,h=a(d).outerHeight();e=d.offsetTop,f=a(c.suggestionsContainer).scrollTop(),g=f+c.options.maxHeight-h,eg&&a(c.suggestionsContainer).scrollTop(e-c.options.maxHeight+h),c.options.preserveInput||(c.ignoreValueChange=!0,c.el.val(c.getValue(c.suggestions[b].value))),c.signalHint(null)}},onSelect:function(b){var c=this,d=c.options.onSelect,e=c.suggestions[b];c.currentValue=c.getValue(e.value),c.currentValue===c.el.val()||c.options.preserveInput||c.el.val(c.currentValue),c.signalHint(null),c.suggestions=[],c.selection=e,a.isFunction(d)&&d.call(c.element,e)},getValue:function(a){var b,c,d=this,e=d.options.delimiter;return e?(b=d.currentValue,c=b.split(e),1===c.length?a:b.substr(0,b.length-c[c.length-1].length)+a):a},dispose:function(){var b=this;b.el.off(".autocomplete").removeData("autocomplete"),a(window).off("resize.autocomplete",b.fixPositionCapture),a(b.suggestionsContainer).remove()}},a.fn.devbridgeAutocomplete=function(c,d){var e="autocomplete";return arguments.length?this.each(function(){var f=a(this),g=f.data(e);"string"==typeof c?g&&"function"==typeof g[c]&&g[c](d):(g&&g.dispose&&g.dispose(),g=new b(this,c),f.data(e,g))}):this.first().data(e)},a.fn.autocomplete||(a.fn.autocomplete=a.fn.devbridgeAutocomplete)}); -------------------------------------------------------------------------------- /www/crossing.py: -------------------------------------------------------------------------------- 1 | from .db import User, MailCode, MailRequest, ProfileRequest, AddressPrivacy, fn_Random 2 | from .mail import mail, Message 3 | from authlib.integrations.flask_client import OAuth 4 | from authlib.common.errors import AuthlibBaseError 5 | from xml.etree import ElementTree as etree 6 | from random import randrange, choices 7 | import os 8 | from flask import ( 9 | Blueprint, session, url_for, redirect, request, 10 | render_template, g, flash, current_app, 11 | send_from_directory 12 | ) 13 | from functools import wraps 14 | from peewee import JOIN, fn 15 | from flask_wtf import FlaskForm 16 | from flask_wtf.csrf import CSRFError 17 | from flask_babel import ( 18 | _, lazy_gettext as _l, pgettext as _p, 19 | format_date, force_locale 20 | ) 21 | from datetime import datetime, timedelta 22 | from wtforms import ( 23 | validators, StringField, TextAreaField, 24 | BooleanField, RadioField 25 | ) 26 | from flask_table import Table, Col, OptCol, LinkCol, BoolNaCol, DateCol 27 | 28 | 29 | oauth = OAuth() 30 | oauth.register( 31 | name='openstreetmap', 32 | api_base_url='https://api.openstreetmap.org/api/0.6/', 33 | access_token_url='https://www.openstreetmap.org/oauth2/token', 34 | authorize_url='https://www.openstreetmap.org/oauth2/authorize', 35 | client_kwargs={'scope': 'read_prefs'}, 36 | ) 37 | 38 | cross = Blueprint('c', __name__) 39 | 40 | 41 | def get_user(): 42 | if session.get('uid'): 43 | user = User.get_or_none(session['uid']) 44 | if user: 45 | # Update active date 46 | now = datetime.now() 47 | if (user.active_on - now).total_seconds() > 3600: 48 | user.active_on = now 49 | user.save() 50 | # Check if the user is confirmed: 1 sent and 1 received 51 | count1 = MailCode.select(MailCode.code).where( 52 | MailCode.sent_by == user, 53 | MailCode.received_on.is_null(False) 54 | ).limit(1).count() 55 | count2 = MailCode.select(MailCode.code).where( 56 | MailCode.sent_to == user, 57 | MailCode.received_on.is_null(False) 58 | ).limit(1).count() 59 | user.is_confirmed = count1 + count2 >= 2 60 | return user 61 | return None 62 | 63 | 64 | def login_requred(f): 65 | @wraps(f) 66 | def decorated_function(*args, **kwargs): 67 | if not g.user: 68 | return redirect(url_for('c.login', next=request.url)) 69 | if not g.user.is_registered: 70 | return redirect(url_for('c.user')) 71 | return f(*args, **kwargs) 72 | return decorated_function 73 | 74 | 75 | @cross.before_request 76 | def before_request(): 77 | g.user = get_user() 78 | # TODO: set site language 79 | 80 | 81 | @cross.errorhandler(CSRFError) 82 | def handle_csrf_error(e): 83 | flash(_('The CSRF token is invalid. Try again maybe.')) 84 | return redirect(url_for('c.front')) 85 | 86 | 87 | @cross.app_template_global() 88 | def dated_url_for(endpoint, **values): 89 | if endpoint == 'static': 90 | filename = values.get('filename', None) 91 | if filename: 92 | file_path = os.path.join(cross.root_path, 93 | endpoint, filename) 94 | values['q'] = int(os.stat(file_path).st_mtime) 95 | return url_for(endpoint, **values) 96 | 97 | 98 | @cross.app_template_global() 99 | def my_format_date(date): 100 | if date.year == datetime.now().year: 101 | return format_date(date, 'd MMMM') 102 | return format_date(date, 'd MMM yyyy') 103 | 104 | 105 | def send_email(user, subject, body): 106 | if not user.email or '@' not in user.email: 107 | return False 108 | if not current_app.config['MAIL_SERVER']: 109 | return False 110 | 111 | header = _p('mail', 'Hi %(name)s,', name=user.name) 112 | footer = 'OSM Cards' 113 | msg = Message( 114 | subject=subject, 115 | body=f'{header}\n\n{body}\n\n{footer}', 116 | from_email=('OSM Cards', current_app.config['MAIL_FROM']), 117 | to=[f'{user.name} <{user.email}>'], 118 | reply_to=[current_app.config['REPLY_TO']] 119 | ) 120 | try: 121 | mail.send(msg) 122 | except OSError as e: 123 | current_app.logger.exception(e) 124 | flash(_('Other user was not notified: %(error)s', error=e)) 125 | return False 126 | return True 127 | 128 | 129 | @cross.route('/robots.txt') 130 | def robots(): 131 | return send_from_directory(current_app.static_folder, request.path[1:]) 132 | 133 | 134 | def generate_user_code(): 135 | letters = 'abcdefghijklmnopqrstuvwxyz123456789' 136 | return ''.join(choices(letters, k=8)) 137 | 138 | 139 | @cross.route('/login') 140 | def login(): 141 | if request.args.get('next'): 142 | session['next'] = request.args['next'] 143 | redirect_uri = url_for('c.auth', _external=True) 144 | return oauth.openstreetmap.authorize_redirect(redirect_uri) 145 | 146 | 147 | @cross.route('/auth') 148 | def auth(): 149 | client = oauth.openstreetmap 150 | try: 151 | client.authorize_access_token() 152 | except AuthlibBaseError: 153 | return _('Authorization denied. Try again.', url_for('c.login')) 154 | 155 | response = client.get('user/details') 156 | user_details = etree.fromstring(response.content) 157 | uid = int(user_details[0].get('id')) 158 | name = user_details[0].get('display_name') 159 | 160 | user = User.get_or_none(osm_uid=uid) 161 | if not user: 162 | # No such user, let's create one 163 | # TODO: proper identifying of languages 164 | lang = request.accept_languages.best_match(['en', 'ru']) or 'en' 165 | user = User.create(name='', site_lang=lang, osm_uid=uid, 166 | osm_name=name, code=generate_user_code()) 167 | flash(_('Welcome! Please fill in your name and address to start ' 168 | 'sending and receiving postcards.'), 'info') 169 | else: 170 | if user.osm_name != name: 171 | user.osm_name = name 172 | user.save() 173 | 174 | session['uid'] = user.id 175 | session.permanent = True 176 | if not user.is_registered: 177 | return redirect(url_for('c.user')) 178 | 179 | if session.get('next'): 180 | redir = session['next'] 181 | del session['next'] 182 | else: 183 | redir = url_for('c.front') 184 | return redirect(redir) 185 | 186 | 187 | @cross.route('/logout') 188 | def logout(): 189 | if 'uid' in session: 190 | del session['uid'] 191 | return redirect(url_for('c.front')) 192 | 193 | 194 | @cross.route('/') 195 | def front(): 196 | if not g.user: 197 | return render_template('index.html') 198 | if not g.user.is_registered: 199 | return redirect(url_for('c.user', first=1)) 200 | 201 | mailcodes = MailCode.select().where( 202 | MailCode.sent_by == g.user, 203 | MailCode.is_active == True, 204 | MailCode.sent_on.is_null(True) 205 | ).order_by(MailCode.created_on.desc()) 206 | sent_cards = MailCode.select().where( 207 | MailCode.sent_by == g.user, 208 | MailCode.is_active == True, 209 | MailCode.sent_on.is_null(False) 210 | ).order_by(MailCode.sent_on.desc()) 211 | delivered_cards = MailCode.select().where( 212 | ((MailCode.sent_by == g.user) | (MailCode.sent_to == g.user)) & 213 | (MailCode.received_on.is_null(False)) 214 | ).order_by(MailCode.received_on.desc()).limit(10) 215 | requests = MailRequest.select().where( 216 | MailRequest.requested_from == g.user, 217 | MailRequest.is_hidden == False, 218 | MailRequest.is_active == True 219 | ).order_by(MailRequest.created_on) 220 | addr_requests = ProfileRequest.select().where( 221 | ProfileRequest.requested_from == g.user, 222 | ProfileRequest.granted.is_null(True) 223 | ).order_by(ProfileRequest.created_on) 224 | 225 | return render_template( 226 | 'front.html', mailcodes=mailcodes, sent_cards=sent_cards, 227 | addr_requests=addr_requests, 228 | requests=requests, delivered_cards=delivered_cards) 229 | 230 | 231 | class UserForm(FlaskForm): 232 | name = StringField( 233 | _l('Your name'), 234 | description=_l('Usually the real one, or whatever you prefer — ' 235 | 'for postcards and the profile'), 236 | validators=[validators.DataRequired(), validators.Length(min=2)] 237 | ) 238 | email = StringField( 239 | _l('Email'), 240 | description=_l('We won\'t send you anything besides notifications'), 241 | validators=[validators.Optional(), validators.Regexp(r'^[^@]+@.+\.\w+$')]) 242 | 243 | description = TextAreaField(_l('Write some words about yourself and what you like')) 244 | country = StringField(_l('Country'), validators=[validators.Optional()]) 245 | # country = SelectField( 246 | # 'Country', 247 | # description='Please excuse me for not using autocomplete yet', 248 | # choices=[('', ''), ('BY', 'Belarus'), ('RU', 'Russian Federation')], 249 | # validators=[validators.DataRequired()] 250 | # ) 251 | address = TextAreaField( 252 | _l('Your postal address, in latin letters'), 253 | validators=[validators.DataRequired()] 254 | ) 255 | languages = StringField(_l('Languages you can read, comma-separated')) 256 | does_requests = BooleanField(_l('I send postcards on request')) 257 | privacy = RadioField( 258 | _l('Who sees my address'), 259 | choices=[ 260 | (2, _l('Anybody at random')), 261 | (4, _l('Confirmed users at random and profile visitors')), 262 | (6, _l('Profile visitors only')), 263 | (8, _l('Profile visitors, only after I accept')), 264 | (10, _l('Nobody')), 265 | ], 266 | coerce=int 267 | ) 268 | 269 | 270 | @cross.route('/user', methods=('GET', 'POST')) 271 | def user(): 272 | if not g.user: 273 | return redirect(url_for('c.front')) 274 | user = get_user() 275 | form = UserForm(obj=user) 276 | if form.is_submitted(): 277 | if not form.validate(): 278 | flash(_('There are some errors, please fix them.')) 279 | else: 280 | form.populate_obj(user) 281 | for k in ('country', 'email', 'description', 'address'): 282 | v = getattr(form, k).data 283 | if v is None or not v.strip(): 284 | setattr(user, k, None) 285 | user.save() 286 | flash(_('Profile has been updated.'), 'info') 287 | return redirect(url_for('c.user')) 288 | 289 | MailCodeAlias = MailCode.alias() 290 | count_confirmed = ( 291 | User.select(fn.Count(fn.Distinct(User.id))) 292 | .join_from(User, MailCode, on=( 293 | (MailCode.sent_by == User.id) & (MailCode.received_on.is_null(False)) 294 | )) 295 | .join_from(User, MailCodeAlias, on=( 296 | (MailCodeAlias.sent_to == User.id) & (MailCodeAlias.received_on.is_null(False)) 297 | )).where(User.is_active == True).scalar() 298 | ) 299 | return render_template('settings.html', form=form, count_confirmed=count_confirmed) 300 | 301 | 302 | def find_user_to_send(): 303 | max_privacy = AddressPrivacy.CONFIRMED if g.user.is_confirmed else AddressPrivacy.OPEN 304 | q = User.select().join(MailCode, on=( 305 | (MailCode.sent_by == g.user) & (MailCode.sent_to == User.id) & 306 | ((MailCode.is_active == True) | MailCode.received_on.is_null(False)) 307 | ), join_type=JOIN.LEFT_OUTER).where( 308 | User.id != g.user.id, 309 | User.is_active == True, 310 | User.name.is_null(False), 311 | User.name != '', 312 | User.address.is_null(False), 313 | User.address != '', 314 | User.privacy <= max_privacy, 315 | MailCode.code.is_null(True), 316 | ).order_by(fn_Random()) 317 | return q.get() 318 | 319 | 320 | @cross.route('/send') 321 | @login_requred 322 | def send(): 323 | max_cards = 4 if not g.user.is_confirmed else 8 324 | has_cards = MailCode.select().where( 325 | MailCode.sent_by == g.user, 326 | MailCode.is_active == True 327 | ).count() 328 | if has_cards >= max_cards: 329 | flash(_('You have got too many cards travelling, ' 330 | 'please wait until some of these are delivered.')) 331 | return redirect(url_for('c.front')) 332 | 333 | try: 334 | find_user_to_send() 335 | except User.DoesNotExist: 336 | flash(_('No users without your postcards left, sorry.')) 337 | return redirect(url_for('c.front')) 338 | return render_template('send.html') 339 | 340 | 341 | def generate_mail_code(): 342 | try: 343 | tries = 10 344 | while tries > 0: 345 | code = randrange(1e4, 1e5) 346 | MailCode.get_by_id(code) 347 | tries -= 1 348 | return None 349 | except MailCode.DoesNotExist: 350 | return code 351 | 352 | 353 | @cross.route('/dosend', methods=['POST']) 354 | @login_requred 355 | def dosend(): 356 | code = generate_mail_code() 357 | if not code: 358 | flash(_('Failed to generate a mail code.')) 359 | return redirect(url_for('c.front')) 360 | 361 | user_code = request.form.get('user') 362 | if user_code: 363 | user = User.get_or_none(User.code == user_code) 364 | if not user: 365 | flash(_('There is no user with this private code.')) 366 | return redirect(url_for('c.front')) 367 | lastcode = MailCode.get_or_none( 368 | MailCode.sent_by == g.user, 369 | MailCode.sent_to == user, 370 | MailCode.is_active == True 371 | ) 372 | if lastcode: 373 | flash(_('You are already sending them a postcard.')) 374 | return redirect(url_for('c.profile', pcode=user_code)) 375 | else: 376 | user = find_user_to_send() 377 | MailCode.create(code=code, sent_by=g.user, sent_to=user, sent_address=user.address) 378 | 379 | # Clear postcard requests 380 | they_requested = MailRequest.get_or_none( 381 | MailRequest.requested_by == user, 382 | MailRequest.requested_from == g.user, 383 | MailRequest.is_active == True 384 | ) 385 | if they_requested: 386 | they_requested.is_active = False 387 | they_requested.save() 388 | 389 | return redirect(url_for('c.profile', scode=code)) 390 | 391 | 392 | @cross.route('/request', methods=['POST']) 393 | @login_requred 394 | def req(): 395 | code = request.form.get('user') 396 | user = User.get_or_none(User.code == code) 397 | if not user: 398 | flash(_('There is no user with this private code.')) 399 | return redirect(url_for('c.front')) 400 | 401 | old_req = MailRequest.get_or_none( 402 | MailRequest.requested_by == g.user, 403 | MailRequest.requested_from == user, 404 | MailRequest.is_active == True 405 | ) 406 | if old_req: 407 | flash(_('Sorry, you have already made a request.')) 408 | return redirect(url_for('c.profile', pcode=code)) 409 | MailRequest.create( 410 | requested_by=g.user, requested_from=user, 411 | comment='Hey, please send me a postcard!' 412 | ) 413 | with force_locale(user.site_lang): 414 | send_email(user, _p('mail', '%(user)s wants your postcard', user=g.user.name), 415 | '{}\n\n{}'.format( 416 | _p('mail', '%(user)s has asked you to send them a postcard. ' 417 | 'Please click on the button in their profile and send one!', 418 | user=g.user.name), 419 | url_for('c.profile', pcode=g.user.code, _external=True)) 420 | ) 421 | flash(_('Your postcard request has been sent.'), 'info') 422 | return redirect(url_for('c.profile', pcode=code)) 423 | 424 | 425 | @cross.route('/ask', methods=['POST']) 426 | @login_requred 427 | def ask(): 428 | code = request.form.get('user') 429 | user = User.get_or_none(User.code == code) 430 | if not user: 431 | flash(_('There is no user with this private code.')) 432 | return redirect(url_for('c.front')) 433 | 434 | old_req = ProfileRequest.get_or_none( 435 | ProfileRequest.requested_by == g.user, 436 | ProfileRequest.requested_from == user 437 | ) 438 | if old_req: 439 | flash(_('Sorry, you have already made a request.')) 440 | return redirect(url_for('c.profile', pcode=code)) 441 | 442 | req = ProfileRequest.create(requested_by=g.user, requested_from=user) 443 | with force_locale(user.site_lang): 444 | send_email(user, _p('mail', '%(user)s wants to send you a postcard', user=g.user.name), 445 | '{}:\n\n{}'.format( 446 | _p('mail', '%(user)s has asked you to open your address to them, ' 447 | 'so that they could send you a postcard. Please click on the link ' 448 | 'to grant them permission, or ignore this message to deny', 449 | user=g.user.name), 450 | url_for('c.grant', code=req.id, _external=True)) 451 | ) 452 | flash(_('Permission request sent.', 'info')) 453 | return redirect(url_for('c.profile', pcode=code)) 454 | 455 | 456 | @cross.route('/grant/') 457 | @login_requred 458 | def grant(code): 459 | req = ProfileRequest.get_or_none( 460 | ProfileRequest.id == code, 461 | ProfileRequest.requested_from == g.user 462 | ) 463 | if not req: 464 | flash(_('There is no request with this code')) 465 | return redirect(url_for('c.front')) 466 | 467 | req.granted = True 468 | req.save() 469 | 470 | user = req.requested_by 471 | with force_locale(user.site_lang): 472 | send_email(user, _p('mail', '%(user)s wants your postcard', user=g.user.name), 473 | '{}\n\n{}'.format( 474 | _p('mail', '%(user)s has accepted your request to send them a postcard. ' 475 | 'Please click on the button in their profile and send one!', 476 | user=g.user.name), 477 | url_for('c.profile', pcode=g.user.code, _external=True)) 478 | ) 479 | flash(_('Thank you for granting permission to send you a postcard!'), 'info') 480 | if user.privacy < AddressPrivacy.CLOSED: 481 | return redirect(url_for('c.profile', pcode=user.code)) 482 | return redirect(url_for('c.front')) 483 | 484 | 485 | @cross.route('/profile') 486 | @cross.route('/profile/') 487 | @cross.route('/send/') 488 | @login_requred 489 | def profile(pcode=None, scode=None): 490 | # 6 requests for one page! Refactor this somehow maybe. 491 | mailcode = None 492 | if pcode: 493 | puser = User.get_or_none(User.code == pcode) 494 | if not puser or not puser.is_registered: 495 | flash(_('There is no user with this private code.')) 496 | return redirect(url_for('c.front')) 497 | elif scode: 498 | mailcode = MailCode.get_or_none(MailCode.code == scode) 499 | if not mailcode or mailcode.sent_by != g.user: 500 | flash(_('No such mailcode.')) 501 | return redirect(url_for('c.front')) 502 | if mailcode.received_on: 503 | return redirect(url_for('c.card', code=mailcode.code)) 504 | puser = mailcode.sent_to 505 | else: 506 | puser = g.user 507 | if not puser: 508 | # Should not happen 509 | return _('Sorry, no user with this code.') 510 | 511 | prequest = recent_postcard = they_requested = asked_for_address = None 512 | can_send = can_request = can_ask = recently_registered = can_see_address = False 513 | is_me = g.user == puser 514 | if not is_me: 515 | if puser.privacy == AddressPrivacy.ASK: 516 | my_request = ProfileRequest.get_or_none( 517 | ProfileRequest.requested_by == g.user, 518 | ProfileRequest.requested_from == puser 519 | ) 520 | if not my_request: 521 | can_ask = True 522 | else: 523 | can_see_address = my_request.granted 524 | else: 525 | can_see_address = puser.privacy <= AddressPrivacy.PROFILE 526 | 527 | if g.user.privacy == AddressPrivacy.ASK: 528 | asked_for_address = ProfileRequest.get_or_none( 529 | ProfileRequest.requested_from == g.user, 530 | ProfileRequest.requested_by == puser, 531 | ProfileRequest.granted.is_null(True) 532 | ) 533 | 534 | prequest = MailRequest.get_or_none( 535 | MailRequest.requested_by == g.user, 536 | MailRequest.requested_from == puser, 537 | MailRequest.is_active == True 538 | ) 539 | they_requested = MailRequest.get_or_none( 540 | MailRequest.requested_by == puser, 541 | MailRequest.requested_from == g.user, 542 | MailRequest.is_active == True 543 | ) 544 | they_sending = MailCode.get_or_none( 545 | MailCode.sent_by == puser, 546 | MailCode.sent_to == g.user, 547 | MailCode.is_active == True 548 | ) is not None 549 | if not mailcode: 550 | mailcode = MailCode.get_or_none( 551 | MailCode.sent_by == g.user, 552 | MailCode.sent_to == puser, 553 | MailCode.is_active == True 554 | ) 555 | try: 556 | recent_postcard = MailCode.select().where( 557 | MailCode.sent_by == puser, 558 | MailCode.sent_to == g.user, 559 | MailCode.received_on.is_null(False) 560 | ).order_by(MailCode.received_on.desc()).get() 561 | except MailCode.DoesNotExist: 562 | pass 563 | 564 | recently_registered = ( 565 | recent_postcard and 566 | recent_postcard.received_on >= datetime.now() - timedelta(days=1) 567 | ) 568 | can_send = not mailcode and (they_requested or can_see_address) 569 | can_request = (not scode and not they_requested and puser.does_requests and 570 | not recent_postcard and not they_sending) 571 | 572 | return render_template( 573 | 'profile.html', 574 | user=puser, 575 | me=is_me, 576 | code=mailcode, 577 | req=prequest, 578 | from_mailcode=scode is not None, 579 | can_send=can_send, 580 | can_ask=can_ask, 581 | can_request=can_request, 582 | can_see_address=can_see_address, 583 | they_requested=they_requested, 584 | asked_for_address=asked_for_address, 585 | recent_card=None if not recently_registered else recent_postcard 586 | ) 587 | 588 | 589 | @cross.route('/togglesent/') 590 | @login_requred 591 | def togglesent(code): 592 | mailcode = MailCode.get_or_none(MailCode.code == code) 593 | if not mailcode or mailcode.sent_by != g.user: 594 | flash(_('No such mailcode.')) 595 | return redirect(url_for('c.front')) 596 | if mailcode.sent_on: 597 | mailcode.sent_on = None 598 | else: 599 | mailcode.sent_on = datetime.now() 600 | mailcode.save() 601 | return redirect(url_for('c.profile', scode=code)) 602 | 603 | 604 | @cross.route('/card/') 605 | @login_requred 606 | def card(code): 607 | mailcode = MailCode.get_or_none( 608 | (MailCode.code == code) & 609 | ((MailCode.sent_by == g.user) | (MailCode.sent_to == g.user)) 610 | ) 611 | if not mailcode: 612 | flash(_('Cannot find a postcard with this code. Please check it again.')) 613 | return redirect(url_for('c.front')) 614 | if not mailcode.received_on: 615 | if mailcode.sent_by == g.user: 616 | # If the card was not received, show the user profile 617 | return redirect(url_for('c.profile', scode=mailcode.code)) 618 | else: 619 | # User should not know the code before they've received the card. 620 | # Let's nudge them towards the registering page. 621 | return redirect(url_for('c.register')) 622 | other_user = mailcode.sent_by if mailcode.sent_to == g.user else mailcode.sent_to 623 | return render_template( 624 | 'card.html', code=mailcode, from_me=mailcode.sent_by == g.user, 625 | other_user=other_user, 626 | can_see_profile=other_user.privacy < AddressPrivacy.CLOSED) 627 | 628 | 629 | class RegisterForm(FlaskForm): 630 | code = StringField( 631 | _l('Code on the postcard'), 632 | validators=[validators.DataRequired(), validators.Length(min=5, max=5)] 633 | ) 634 | comment = TextAreaField( 635 | _l('Please send the user a comment about their postcard') 636 | ) 637 | 638 | 639 | @cross.route('/register', methods=['GET', 'POST']) 640 | @login_requred 641 | def register(): 642 | form = RegisterForm() 643 | if not form.validate_on_submit(): 644 | return render_template('register.html', form=form) 645 | 646 | code = form.code.data 647 | mailcode = MailCode.get_or_none( 648 | MailCode.code == MailCode.restore_code(code), 649 | MailCode.sent_to == g.user, 650 | ) 651 | if not mailcode: 652 | flash(_('Cannot find a postcard with this code. Please check it again.')) 653 | return render_template('register.html', form=form) 654 | 655 | if not mailcode.is_active: 656 | flash(_('This postcard has already been registered. Thank you!'), 'info') 657 | return redirect(url_for('c.card', code=mailcode.code)) 658 | 659 | comment = form.comment.data.strip() 660 | if comment: 661 | mailcode.comment = comment 662 | mailcode.received_on = datetime.now() 663 | mailcode.is_active = False 664 | mailcode.save() 665 | 666 | with force_locale(mailcode.sent_by.site_lang or 'en'): 667 | send_email( 668 | mailcode.sent_by, 669 | _p('mail', 'Your postcard %(code)s has been received', code=mailcode.lcode), 670 | '{}{}\n\n{}'.format( 671 | _p('mail', 'Your postcard to %(user)s has arrived and has been registered ' 672 | 'just now!', user=g.user.name), 673 | '' if not comment else ' {}:\n\n{}'.format( 674 | _p('mail', 'They have left a reply to it'), comment 675 | ), 676 | url_for('c.card', code=mailcode.code, _external=True)) 677 | ) 678 | 679 | flash(_('Thank you for registering the postcard!'), 'info') 680 | return redirect(url_for('c.card', code=mailcode.code)) 681 | 682 | 683 | @cross.route('/comment/', methods=['POST']) 684 | @login_requred 685 | def comment(code): 686 | mailcode = MailCode.get_or_none( 687 | MailCode.code == code, 688 | MailCode.received_on.is_null(False), 689 | MailCode.sent_to == g.user 690 | ) 691 | if not mailcode: 692 | flash(_('No such mailcode.')) 693 | return redirect(url_for('c.front')) 694 | comment = request.form.get('comment', '').strip() 695 | if comment: 696 | if mailcode.comment: 697 | flash(_('Cannot change already stored comment, sorry.')) 698 | else: 699 | mailcode.comment = comment 700 | mailcode.save() 701 | with force_locale(mailcode.sent_by.site_lang or 'en'): 702 | send_email( 703 | mailcode.sent_by, 704 | _p('mail', 'Comment on your postcard %(code)s', code=mailcode.lcode), 705 | '{}:\n\n{}\n\n{}'.format( 706 | _p('mail', '%(user)s has just left a reply to your postcard', 707 | user=g.user.name), 708 | comment, url_for('c.card', code=mailcode.code, _external=True)) 709 | ) 710 | flash(_('Comment sent, thank you for connecting!'), 'info') 711 | return redirect(url_for('c.card', code=mailcode.code)) 712 | 713 | 714 | class LCodeCol(Col): 715 | def td_format(self, content): 716 | s = str(content) 717 | CODE_LETTERS = 'ABCFJKMNPT' 718 | return CODE_LETTERS[int(s[0]) - 1] + s[1:] 719 | 720 | 721 | class UsersTable(Table): 722 | classes = ['table', 'table-sm'] 723 | id = Col('id') 724 | created_on = DateCol('Created', date_format='d.MM.yyyy') 725 | name = LinkCol('Name', 'c.profile', attr='name', url_kwargs={'pcode': 'code'}) 726 | osm_name = Col('OSM Name') 727 | country = Col('Country') 728 | privacy = OptCol('Privacy', { 729 | 2: 'Open', 4: 'Confirmed', 6: 'Profile', 730 | 8: 'Ask', 10: 'Closed' 731 | }) 732 | does_requests = BoolNaCol('Requests?') 733 | is_active = BoolNaCol('Active?') 734 | 735 | 736 | class CodesTable(Table): 737 | classes = ['table', 'table-sm'] 738 | code = LCodeCol('Code') 739 | sent_by = Col('From') 740 | sent_to = Col('To') 741 | created_on = DateCol('Created', date_format='d.MM.yyyy') 742 | sent_on = DateCol('Sent', date_format='d.MM.yyyy') 743 | 744 | 745 | class ReceivedTable(Table): 746 | classes = ['table', 'table-sm'] 747 | code = LCodeCol('Code') 748 | sent_by = Col('From') 749 | sent_to = Col('To') 750 | created_on = DateCol('Created', date_format='d.MM.yyyy') 751 | sent_on = DateCol('Sent', date_format='d.MM.yyyy') 752 | received_on = DateCol('Received', date_format='d.MM.yyyy') 753 | 754 | 755 | class RequestsTable(Table): 756 | classes = ['table', 'table-sm'] 757 | created_on = DateCol('Created', date_format='d.MM.yyyy') 758 | req_by = Col('By') 759 | req_from = Col('From') 760 | is_hidden = BoolNaCol('Hidden?') 761 | is_active = BoolNaCol('Active?') 762 | 763 | 764 | @cross.route('/admin') 765 | @login_requred 766 | def admin(): 767 | if not g.user.is_admin: 768 | return redirect(url_for('c.front')) 769 | panel = request.args.get('panel', 'log') 770 | 771 | User2 = User.alias() 772 | codes = CodesTable( 773 | MailCode.select( 774 | MailCode.code, User.name.alias('sent_by'), 775 | User2.name.alias('sent_to'), 776 | MailCode.created_on, MailCode.sent_on 777 | ) 778 | .join(User, on=MailCode.sent_by == User.id) 779 | .join_from(MailCode, User2, on=MailCode.sent_to) 780 | .where(MailCode.is_active == True) 781 | .order_by(MailCode.created_on.desc()).limit(30) 782 | .dicts() 783 | ) 784 | received = ReceivedTable( 785 | MailCode.select( 786 | MailCode.code, User.name.alias('sent_by'), 787 | User2.name.alias('sent_to'), MailCode.created_on, 788 | MailCode.sent_on, MailCode.received_on, 789 | MailCode.is_active 790 | ) 791 | .join(User, on=MailCode.sent_by == User.id) 792 | .join_from(MailCode, User2, on=MailCode.sent_to) 793 | .where(MailCode.received_on.is_null(False)) 794 | .order_by(MailCode.received_on.desc()).limit(30) 795 | .dicts() 796 | ) 797 | requests = RequestsTable( 798 | MailRequest.select( 799 | MailRequest.created_on, MailRequest.is_active, 800 | MailRequest.is_hidden, User.name.alias('req_by'), 801 | User2.name.alias('req_from') 802 | ) 803 | .join(User, on=MailRequest.requested_by) 804 | .join_from(MailRequest, User2, on=MailRequest.requested_from) 805 | .order_by(MailRequest.created_on.desc()).limit(30) 806 | .dicts() 807 | ) 808 | users = UsersTable( 809 | User.select( 810 | User.id, User.created_on, User.name, User.osm_name, 811 | User.country, User.privacy, User.does_requests, User.is_active, 812 | User.code 813 | ) 814 | .where(User.address.is_null(False)) 815 | .order_by(User.created_on.desc()).limit(50) 816 | ) 817 | 818 | lines = [] 819 | try: 820 | line = 0 821 | need_lines = 100 822 | with open('/var/log/supervisor/osmcards-error.log', 'r') as f: 823 | for line_str in f: 824 | if line < need_lines: 825 | lines.append(line_str) 826 | else: 827 | lines[line % need_lines] = line_str 828 | line += 1 829 | lines = lines[line % need_lines:] + lines[:line % need_lines] 830 | except OSError: 831 | pass 832 | 833 | return render_template( 834 | 'admin.html', panel=panel, codes=codes, received=received, 835 | requests=requests, users=users, log=lines) 836 | 837 | 838 | @cross.route('/set-lang', methods=['POST']) 839 | @login_requred 840 | def set_lang(): 841 | user = g.user 842 | if not user: 843 | return redirect(url_for('c.front')) 844 | new_lang = request.form['lang'] 845 | # Proper list of languages 846 | if new_lang != user.lang and new_lang in ['en', 'ru']: 847 | user.lang = new_lang 848 | user.save() 849 | return redirect(request.form.get('redirect', url_for('c.user'))) 850 | --------------------------------------------------------------------------------