├── .csslintrc ├── .devcontainer ├── Dockerfile ├── devcontainer.json ├── docker-compose.yml └── start-message.text ├── .dockerignore ├── .envrc ├── .git-blame-ignore-revs ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .zed └── settings.json ├── Dockerfile ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── devbox.json ├── devbox.lock ├── fly.toml ├── manage.py ├── osmcal ├── __init__.py ├── api │ ├── __init__.py │ ├── compatserializers.py │ ├── compatviews.py │ ├── decorators.py │ ├── schema │ │ └── api.yaml │ ├── serializers.py │ ├── test_views.py │ ├── urls.py │ └── views.py ├── fixtures │ └── demo.yaml ├── forms.py ├── ical.py ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── middlewares │ ├── __init__.py │ └── replay_middleware.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20190511_0926.py │ ├── 0003_auto_20190511_0954.py │ ├── 0004_auto_20190512_1620.py │ ├── 0005_event_description.py │ ├── 0006_auto_20190731_1340.py │ ├── 0007_event_location_text.py │ ├── 0008_auto_20190801_0800.py │ ├── 0009_eventparticipation.py │ ├── 0010_auto_20191010_1907.py │ ├── 0011_event_location_name.py │ ├── 0012_auto_20191019_1005.py │ ├── 0013_remove_event_created_by.py │ ├── 0014_auto_20200201_1440.py │ ├── 0015_auto_20200215_1726.py │ ├── 0016_auto_20200221_1643.py │ ├── 0017_remove_eventparticipation_answers.py │ ├── 0018_auto_20200221_1810.py │ ├── 0019_auto_20200222_1056.py │ ├── 0020_eventparticipation_added_on.py │ ├── 0021_auto_20200223_1808.py │ ├── 0022_event_cancelled.py │ ├── 0023_user_primary_key.py │ ├── 0024_auto_20200516_1213.py │ ├── 0025_auto_20200917_1515.py │ ├── 0026_event_timezone.py │ ├── 0027_timezone_data.py │ ├── 0028_user_home_location.py │ ├── 0029_alter_event_description.py │ ├── 0030_alter_eventparticipation_unique_together.py │ ├── 0031_event_hidden.py │ ├── 0032_user_is_moderator.py │ ├── 0033_user_is_banned.py │ └── __init__.py ├── models.py ├── oauth.py ├── osmuser.py ├── serializers.py ├── settings.py ├── social │ ├── __init__.py │ ├── apps.py │ ├── management │ │ └── commands │ │ │ └── twitterkey.py │ ├── mastodon.py │ └── post.py ├── static │ └── osmcal │ │ ├── event-form.css │ │ ├── favicon.png │ │ ├── osm_logo.png │ │ ├── source-sans-pro │ │ ├── SIL Open Font License.txt │ │ ├── SourceSansPro-Black.otf │ │ ├── SourceSansPro-BlackIt.otf │ │ ├── SourceSansPro-Bold.otf │ │ ├── SourceSansPro-BoldIt.otf │ │ ├── SourceSansPro-ExtraLight.otf │ │ ├── SourceSansPro-ExtraLightIt.otf │ │ ├── SourceSansPro-It.otf │ │ ├── SourceSansPro-Light.otf │ │ ├── SourceSansPro-LightIt.otf │ │ ├── SourceSansPro-Regular.otf │ │ ├── SourceSansPro-Semibold.otf │ │ └── SourceSansPro-SemiboldIt.otf │ │ ├── style-mobile.css │ │ ├── style.css │ │ ├── survey-form.css │ │ ├── thirdparty │ │ ├── Control.OSMGeocoder.css │ │ ├── Control.OSMGeocoder.js │ │ ├── flatpickr.js │ │ ├── flatpickr.min.css │ │ ├── vue.js │ │ └── vue.min.js │ │ ├── touch-icon.png │ │ └── triangle.svg ├── templates │ ├── 400.html │ ├── 404.html │ ├── 500.html │ ├── base.html │ └── osmcal │ │ ├── date-base.txt │ │ ├── date-short-base.txt │ │ ├── date-short.l10n.txt │ │ ├── date-short.txt │ │ ├── date.l10n.txt │ │ ├── date.txt │ │ ├── documentation.html │ │ ├── event.html │ │ ├── event_form.html │ │ ├── event_join.html │ │ ├── event_participants.html │ │ ├── event_survey.html │ │ ├── events_past.html │ │ ├── feeds │ │ └── event_feed.html │ │ ├── homepage.html │ │ ├── login.html │ │ ├── partials │ │ ├── event_form_timezone.html │ │ ├── event_list.html │ │ ├── leaflet_widget.html │ │ └── no_index.html │ │ ├── subscription_info.html │ │ └── user_self.html ├── templatetags │ ├── __init__.py │ ├── locadate.py │ ├── markdown.py │ ├── schema.py │ └── tabless.py ├── test_dateformat.py ├── test_feeds.py ├── test_ical.py ├── test_views.py ├── urls.py ├── views.py ├── widgets.py └── wsgi.py ├── postgres ├── flake.lock └── flake.nix ├── process-compose.yml ├── pyproject.toml └── uv.lock /.csslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["box-sizing"] 3 | 4 | } 5 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | ENV PYTHONDONTWRITEBYTECODE 1 5 | ENV DEBIAN_FRONTEND noninteractive 6 | 7 | RUN apt-get update && apt-get -y install curl make libgdal32 8 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 9 | 10 | CMD /bin/bash 11 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "osmcal", 3 | "service": "osmcal", 4 | "dockerComposeFile": "docker-compose.yml", 5 | "workspaceFolder": "/app", 6 | "postCreateCommand": "make install-dev && ln -s /app/.venv/bin/black /usr/local/bin/black && cat .devcontainer/start-message.text", 7 | "postStartCommand": "DEVSERVER_ARGS=0:8000 make devserver", 8 | "shutdownAction": "stopCompose", 9 | "customizations": { 10 | "vscode": { 11 | "extensions": ["ms-python.python", "ms-python.black-formatter"] 12 | } 13 | }, 14 | "forwardPorts": ["8000", "db:5432"] 15 | } 16 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | osmcal: 3 | build: 4 | context: .. 5 | dockerfile: .devcontainer/Dockerfile 6 | volumes: 7 | - ../:/app 8 | command: sleep infinity 9 | environment: 10 | - "OSMCAL_PG_HOST=db" 11 | - "OSMCAL_PG_PASSWORD=hunter2" 12 | ports: 13 | - "8000:8000" 14 | # network_mode: service:db 15 | 16 | db: 17 | image: postgis/postgis:15-3.4 18 | restart: unless-stopped 19 | volumes: 20 | - postgres-data:/var/lib/postgresql/data 21 | environment: 22 | POSTGRES_USER: osmcal 23 | POSTGRES_DB: osmcal 24 | POSTGRES_PASSWORD: hunter2 25 | 26 | volumes: 27 | postgres-data: 28 | -------------------------------------------------------------------------------- /.devcontainer/start-message.text: -------------------------------------------------------------------------------- 1 | 2 | ██████  ███████ ███  ███  ██████  █████  ██  3 | ██    ██ ██      ████  ████ ██      ██   ██ ██  4 | ██  ██ ███████ ██ ████ ██ ██  ███████ ██  5 | ██  ██      ██ ██  ██  ██ ██  ██   ██ ██  6 |  ██████  ███████ ██      ██  ██████ ██  ██ ███████  7 |                                                8 | 9 | Setup complete. 10 | 11 | You can now run migrations (`make migrate`) and start the dev server (`make devserver`). You can load some demo data (fixtures) by running `make fixtures`. 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .env 3 | .git 4 | kladde* 5 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Automatically sets up your devbox environment whenever you cd into this 4 | # directory via our direnv integration: 5 | 6 | eval "$(devbox generate direnv --print-envrc)" 7 | 8 | # check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/ 9 | # for more details 10 | 11 | export GDAL_LIBRARY_PATH=$(gdal-config --libs | awk '{print $1}' | sed 's/-L//')/libgdal.dylib 12 | export PROJ_LIB=$(gdal-config --datadir) 13 | export GEOS_LIBRARY_PATH=".devbox/nix/profile/default/lib/libgeos_c.dylib" 14 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # .git-blame-ignore-revs 2 | # Migrate code style to Black 3 | 8cf35a5a63153059218f5a65db408b3212c44db2 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [thomersch] 2 | liberapay: thomersch 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | container: python:3.11-slim-bookworm 9 | 10 | services: 11 | postgres: 12 | image: postgis/postgis:15-3.4 13 | env: 14 | POSTGRES_USER: osmcal 15 | POSTGRES_PASSWORD: postgres 16 | options: >- 17 | --health-cmd pg_isready 18 | --health-interval 10s 19 | --health-timeout 5s 20 | --health-retries 5 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v2 25 | 26 | - name: Install other deps 27 | run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y libgdal-dev curl make 28 | 29 | - name: Install uv 30 | run: curl -LsSf https://astral.sh/uv/install.sh | sh 31 | 32 | - name: Install deps 33 | run: make install-dev 34 | 35 | - name: Check formatting 36 | run: uv run black --check . 37 | 38 | - name: Run tests 39 | run: uv run ./manage.py test 40 | env: 41 | OSMCAL_PG_HOST: postgres 42 | OSMCAL_PG_PASSWORD: postgres 43 | 44 | deploy: 45 | if: github.ref == 'refs/heads/master' 46 | runs-on: ubuntu-latest 47 | needs: test 48 | 49 | steps: 50 | - name: Checkout code 51 | uses: actions/checkout@v2 52 | - uses: superfly/flyctl-actions/setup-flyctl@master 53 | - run: flyctl deploy --remote-only --detach 54 | env: 55 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | __pycache__/ 3 | /static/ 4 | .env 5 | kladde* 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | // Folder-specific settings 2 | // 3 | // For a full list of overridable settings, and general information on folder-specific settings, 4 | // see the documentation: https://zed.dev/docs/configuring-zed#folder-specific-settings 5 | { 6 | "language_overrides": { 7 | "Python": { 8 | "format_on_save": { 9 | "external": { 10 | "command": "black", 11 | "arguments": ["-"] 12 | } 13 | } 14 | }, 15 | "HTML": { "format_on_save": "off" } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/redocly/redoc/cli:v2.0.0-rc.72 AS apidocs 2 | 3 | WORKDIR /docs 4 | 5 | COPY osmcal/api/schema . 6 | COPY Makefile . 7 | RUN apk add make && redoc-cli bundle -o api.html --disableGoogleFont api.yaml 8 | 9 | FROM python:3.11-slim 10 | 11 | WORKDIR /app 12 | 13 | ENV PYTHONUNBUFFERED 1 14 | ENV PYTHONDONTWRITEBYTECODE 1 15 | ENV DEBIAN_FRONTEND noninteractive 16 | 17 | RUN apt-get update && apt-get -y install curl make libgdal32 18 | RUN curl -LO https://github.com/DarthSim/hivemind/releases/download/v1.1.0/hivemind-v1.1.0-linux-amd64.gz && gunzip hivemind-v1.1.0-linux-amd64.gz && mv hivemind-v1.1.0-linux-amd64 /usr/local/bin/hivemind && chmod +x /usr/local/bin/hivemind 19 | 20 | RUN useradd -m osmcal 21 | RUN chown osmcal /app 22 | 23 | USER osmcal 24 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 25 | 26 | # This is a hack to speed up docker builds through leveraging the layer cache. 27 | COPY pyproject.toml uv.lock Makefile ./ 28 | RUN make install 29 | 30 | COPY . . 31 | COPY --from=apidocs /docs/api.html osmcal/static/api.html 32 | RUN make install staticfiles 33 | 34 | EXPOSE 8080 35 | CMD hivemind 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019—2021, Thomas Skowron 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CALL := uv 2 | 3 | GUNICORN_WORKERS ?= 1 4 | FLY_REGION ?= "" 5 | WRITABLE_REGION ?= "" 6 | DEVSERVER_ARGS ?= "" 7 | 8 | devserver: 9 | $(CALL) run ./manage.py runserver $(DEVSERVER_ARGS) 10 | 11 | install: 12 | $(CALL) sync --no-dev --frozen 13 | 14 | install-dev: 15 | $(CALL) sync --frozen 16 | 17 | migrate: 18 | @if [ $(FLY_REGION) = $(WRITABLE_REGION) ]; then \ 19 | $(CALL) run ./manage.py migrate;\ 20 | else \ 21 | echo "Running on non-writable node";\ 22 | fi 23 | 24 | makemigrations: 25 | $(CALL) run ./manage.py makemigrations 26 | 27 | staticfiles: 28 | $(CALL) run ./manage.py collectstatic --noinput 29 | 30 | gunicorn: 31 | $(CALL) run gunicorn osmcal.wsgi -b :8080 -w $(GUNICORN_WORKERS) --preload --access-logfile - 32 | 33 | test: 34 | $(CALL) run ./manage.py test 35 | 36 | fixtures: 37 | $(CALL) run ./manage.py loaddata osmcal/fixtures/demo.yaml 38 | 39 | processtasks: 40 | $(CALL) run ./manage.py process_tasks 41 | 42 | periodic: 43 | while true; do \ 44 | echo "Running clearsessions" ;\ 45 | $(CALL) run ./manage.py clearsessions ;\ 46 | sleep 86400 ;\ 47 | done; 48 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | gunicorn: make gunicorn 2 | 3 | worker: make processtasks 4 | 5 | periodic: make periodic 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenStreetMap Calendar 2 | 3 | A simple calendar for tracking OpenStreetMap-related activities. 4 | 5 | ## Principles 6 | 7 | * Less, but better. 8 | * Work hard and be nice to people. 9 | 10 | ## User Documentation 11 | 12 | Please look at [OpenStreetMap Calendar Documentation](https://osmcal.org/documentation/) for information about integration and API. 13 | 14 | ## Developer Documentation 15 | 16 | The repo contains a [dev container](https://containers.dev) configuration, so you can develop without having to install all the dependencies on your machine manually. VS Code and PyCharm/IntelliJ have integrated support and you can even use Github Codespaces. 17 | 18 | We also feature a [devbox](https://www.jetify.com/devbox) setup which allows to setup a development environment without having to utilize Docker and containers. `devbox services up` allows you to start the project itself and its dependencies. 19 | 20 | Alternatively you can install like any other Python/Django project. We're using [uv](https://docs.astral.sh/uv/) for managing dependencies. Please look at their documentation for installation instructions. 21 | 22 | We support Python ≥ 3.11 and PostgreSQL ≥ 15. (Older versions might work, but no guarantees). 23 | 24 | ### Database 25 | 26 | You need a running PostgreSQL database with PostGIS installed. If you're using the dev container, the DB is automatically started and set up. 27 | 28 | If you set this up manually, make sure you have an empty DB before starting. By default we're using the `osmcal` for user and DB with no password set. For more details, check `osmcal/settings.py`. 29 | 30 | ### Running Tests 31 | 32 | ``` 33 | make test 34 | ``` 35 | 36 | ### Developer Server 37 | 38 | In order to facilitate testing, you can use a fake login locally without having to setup OAuth first. To do this, scroll down to the footer. In debug mode, there is a link called "Mock login" which will instantly log you in as a normal user. 39 | 40 | To prepare for application launch run the database migrations: 41 | 42 | ``` 43 | make migrate 44 | ``` 45 | 46 | and then the local server: 47 | 48 | ``` 49 | make devserver 50 | ``` 51 | 52 | If you need test data, you can load some using: 53 | 54 | ``` 55 | make fixtures 56 | ``` 57 | 58 | ## API Documentation 59 | 60 | The API is described using OpenAPI 3, the schema is located in `/api/schema/`. The currently live version is [visible here](https://osmcal.org/static/api.html). 61 | -------------------------------------------------------------------------------- /devbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.14.0/.schema/devbox.schema.json", 3 | "packages": ["uv@latest", "path:postgres#postgresql", "python312Packages.gdal@latest", "geos@latest"], 4 | "shell": { 5 | "init_hook": ["echo 'Welcome to devbox!' > /dev/null"], 6 | "scripts": { 7 | "test": ["echo \"Error: no test specified\" && exit 1"] 8 | } 9 | }, 10 | "include": ["plugin:postgresql"] 11 | } 12 | -------------------------------------------------------------------------------- /devbox.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfile_version": "1", 3 | "packages": { 4 | "geos@latest": { 5 | "last_modified": "2025-03-19T06:05:51Z", 6 | "resolved": "github:NixOS/nixpkgs/44e422ba8ed1bdec9620b0733601669972f91170#geos", 7 | "source": "devbox-search", 8 | "version": "3.13.1", 9 | "systems": { 10 | "aarch64-darwin": { 11 | "outputs": [ 12 | { 13 | "name": "out", 14 | "path": "/nix/store/3pxly3hiwlg6i0h8rlc760anqrq8xjkq-geos-3.13.1", 15 | "default": true 16 | } 17 | ], 18 | "store_path": "/nix/store/3pxly3hiwlg6i0h8rlc760anqrq8xjkq-geos-3.13.1" 19 | }, 20 | "aarch64-linux": { 21 | "outputs": [ 22 | { 23 | "name": "out", 24 | "path": "/nix/store/zj9jd6wf11768kw7np49bn7wbcx7yd5j-geos-3.13.1", 25 | "default": true 26 | } 27 | ], 28 | "store_path": "/nix/store/zj9jd6wf11768kw7np49bn7wbcx7yd5j-geos-3.13.1" 29 | }, 30 | "x86_64-darwin": { 31 | "outputs": [ 32 | { 33 | "name": "out", 34 | "path": "/nix/store/604nr4bbw54fyh7m2gsp7lsf9d2af846-geos-3.13.1", 35 | "default": true 36 | } 37 | ], 38 | "store_path": "/nix/store/604nr4bbw54fyh7m2gsp7lsf9d2af846-geos-3.13.1" 39 | }, 40 | "x86_64-linux": { 41 | "outputs": [ 42 | { 43 | "name": "out", 44 | "path": "/nix/store/kpkl0p082w3hsjlrz30hcymwxcmmf59d-geos-3.13.1", 45 | "default": true 46 | } 47 | ], 48 | "store_path": "/nix/store/kpkl0p082w3hsjlrz30hcymwxcmmf59d-geos-3.13.1" 49 | } 50 | } 51 | }, 52 | "github:NixOS/nixpkgs/nixpkgs-unstable": { 53 | "resolved": "github:NixOS/nixpkgs/ebe4301cbd8f81c4f8d3244b3632338bbeb6d49c?lastModified=1744868846&narHash=sha256-5RJTdUHDmj12Qsv7XOhuospjAjATNiTMElplWnJE9Hs%3D" 54 | }, 55 | "python312Packages.gdal@latest": { 56 | "last_modified": "2025-04-10T20:20:34Z", 57 | "resolved": "github:NixOS/nixpkgs/d19cf9dfc633816a437204555afeb9e722386b76#python312Packages.gdal", 58 | "source": "devbox-search", 59 | "version": "3.10.2", 60 | "systems": { 61 | "aarch64-darwin": { 62 | "outputs": [ 63 | { 64 | "name": "out", 65 | "path": "/nix/store/qy50rala671i7y54rcnhw1k1r1inmf1d-gdal-3.10.2", 66 | "default": true 67 | } 68 | ], 69 | "store_path": "/nix/store/qy50rala671i7y54rcnhw1k1r1inmf1d-gdal-3.10.2" 70 | }, 71 | "aarch64-linux": { 72 | "outputs": [ 73 | { 74 | "name": "out", 75 | "path": "/nix/store/pikx6cm9lbpgyin0r5k2w64ly7xpgb88-gdal-3.10.2", 76 | "default": true 77 | } 78 | ], 79 | "store_path": "/nix/store/pikx6cm9lbpgyin0r5k2w64ly7xpgb88-gdal-3.10.2" 80 | }, 81 | "x86_64-darwin": { 82 | "outputs": [ 83 | { 84 | "name": "out", 85 | "path": "/nix/store/aay4vv6bf8l7n6qq8yf0187i60sah1wq-gdal-3.10.2", 86 | "default": true 87 | } 88 | ], 89 | "store_path": "/nix/store/aay4vv6bf8l7n6qq8yf0187i60sah1wq-gdal-3.10.2" 90 | }, 91 | "x86_64-linux": { 92 | "outputs": [ 93 | { 94 | "name": "out", 95 | "path": "/nix/store/ycn05s2pjxz9rg9in62yryfp0r6gp860-gdal-3.10.2", 96 | "default": true 97 | } 98 | ], 99 | "store_path": "/nix/store/ycn05s2pjxz9rg9in62yryfp0r6gp860-gdal-3.10.2" 100 | } 101 | } 102 | }, 103 | "uv@latest": { 104 | "last_modified": "2025-04-10T20:20:34Z", 105 | "resolved": "github:NixOS/nixpkgs/d19cf9dfc633816a437204555afeb9e722386b76#uv", 106 | "source": "devbox-search", 107 | "version": "0.6.14", 108 | "systems": { 109 | "aarch64-darwin": { 110 | "outputs": [ 111 | { 112 | "name": "out", 113 | "path": "/nix/store/9f492dxkf4qkvkr0n950xphmqjh29pwk-uv-0.6.14", 114 | "default": true 115 | } 116 | ], 117 | "store_path": "/nix/store/9f492dxkf4qkvkr0n950xphmqjh29pwk-uv-0.6.14" 118 | }, 119 | "aarch64-linux": { 120 | "outputs": [ 121 | { 122 | "name": "out", 123 | "path": "/nix/store/ana816flfaf6sh76h9xswphq9lyb0iv6-uv-0.6.14", 124 | "default": true 125 | } 126 | ], 127 | "store_path": "/nix/store/ana816flfaf6sh76h9xswphq9lyb0iv6-uv-0.6.14" 128 | }, 129 | "x86_64-darwin": { 130 | "outputs": [ 131 | { 132 | "name": "out", 133 | "path": "/nix/store/z6mxd485vrkyxdw57zlldmsqahpqfsi6-uv-0.6.14", 134 | "default": true 135 | } 136 | ], 137 | "store_path": "/nix/store/z6mxd485vrkyxdw57zlldmsqahpqfsi6-uv-0.6.14" 138 | }, 139 | "x86_64-linux": { 140 | "outputs": [ 141 | { 142 | "name": "out", 143 | "path": "/nix/store/z4ld2kivcl6hmj4k8i5x7wbfmmsfyvm2-uv-0.6.14", 144 | "default": true 145 | } 146 | ], 147 | "store_path": "/nix/store/z4ld2kivcl6hmj4k8i5x7wbfmmsfyvm2-uv-0.6.14" 148 | } 149 | } 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "osmcal" 2 | primary_region = "ams" 3 | kill_signal = "SIGINT" 4 | kill_timeout = 5 5 | swap_size_mb = 512 6 | 7 | [experimental] 8 | auto_rollback = true 9 | 10 | [env] 11 | GUNICORN_WORKERS = "2" 12 | OSMCAL_PG_HOST = "osmcal-db2.flycast" 13 | OSMCAL_PROD = "true" 14 | PORT = "8080" 15 | WRITABLE_REGION = "ams" 16 | 17 | [[services]] 18 | protocol = "tcp" 19 | internal_port = 8080 20 | processes = ["app"] 21 | auto_stop_machines = true 22 | auto_start_machines = true 23 | min_machines_running = 1 24 | 25 | [[services.ports]] 26 | port = 80 27 | handlers = ["http"] 28 | force_https = true 29 | 30 | [[services.ports]] 31 | port = 443 32 | handlers = ["tls", "http"] 33 | 34 | [services.concurrency] 35 | type = "connections" 36 | hard_limit = 125 37 | soft_limit = 100 38 | 39 | [[services.http_checks]] 40 | interval = "10s" 41 | timeout = "2s" 42 | grace_period = "3s" 43 | restart_limit = 10 44 | method = "get" 45 | path = "/" 46 | protocol = "http" 47 | 48 | [[statics]] 49 | guest_path = "/app/static/" 50 | url_prefix = "/static/" 51 | 52 | [metrics] 53 | port = 8080 54 | path = "/metrics" 55 | 56 | [deploy] 57 | release_command = "make migrate" 58 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "osmcal.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /osmcal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/__init__.py -------------------------------------------------------------------------------- /osmcal/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/api/__init__.py -------------------------------------------------------------------------------- /osmcal/api/compatserializers.py: -------------------------------------------------------------------------------- 1 | from django.template.loader import render_to_string 2 | 3 | from .serializers import EventsSerializer 4 | 5 | """Serializers that are used by older versions of the API""" 6 | 7 | 8 | class EventsV1Serializer(EventsSerializer): 9 | def attr_date(self, obj): 10 | o = { 11 | "human": render_to_string("osmcal/date.l10n.txt", {"event": obj}).strip(), 12 | "whole_day": obj.whole_day, 13 | "start": str(obj.start_localized.replace(tzinfo=None)), 14 | } 15 | if obj.end: 16 | o["end"] = str(obj.end_localized.replace(tzinfo=None)) 17 | return o 18 | -------------------------------------------------------------------------------- /osmcal/api/compatviews.py: -------------------------------------------------------------------------------- 1 | from .compatserializers import EventsV1Serializer 2 | from .views import EventList, PastEventList 3 | 4 | """Views that implement older versions of the API.""" 5 | 6 | 7 | class EventListV1(EventList): 8 | def get_serializer(self): 9 | return EventsV1Serializer 10 | 11 | 12 | class PastEventListV1(PastEventList): 13 | def get_serializer(self): 14 | return EventsV1Serializer 15 | -------------------------------------------------------------------------------- /osmcal/api/decorators.py: -------------------------------------------------------------------------------- 1 | from django.utils import translation 2 | 3 | ALLOWED_HEADERS = ["Client-App"] 4 | 5 | 6 | def cors_any(handler): 7 | def wrapper(*args, **kwargs): 8 | resp = handler(*args, **kwargs) 9 | resp["Access-Control-Allow-Origin"] = "*" 10 | resp["Access-Control-Allow-Headers"] = ", ".join(ALLOWED_HEADERS) 11 | return resp 12 | 13 | return wrapper 14 | 15 | 16 | def language_from_header(handler): 17 | def wrapper(obj, request, *args, **kwargs): 18 | translation.activate(translation.get_language_from_request(request)) 19 | request.LANGUAGE_CODE = translation.get_language() 20 | r = handler(obj, request, *args, **kwargs) 21 | translation.deactivate() 22 | return r 23 | 24 | return wrapper 25 | -------------------------------------------------------------------------------- /osmcal/api/schema/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | version: '2.2' 4 | title: OSMCAL API 5 | license: 6 | name: Apache 2.0 7 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 8 | servers: 9 | - url: 'https://osmcal.org/api' 10 | tags: 11 | - name: Events 12 | paths: 13 | /v2/events/: 14 | get: 15 | description: Retrieve list of upcoming events 16 | tags: 17 | - Events 18 | parameters: 19 | - name: in 20 | in: query 21 | description: Limit returned events to a given country. The country should be specified as ISO 3166-1 alpha-2 code. 22 | required: false 23 | example: "de" 24 | schema: 25 | type: string 26 | - name: Accept-Language 27 | in: header 28 | schema: 29 | type: string 30 | example: 'pt-BR' 31 | description: The user's locale. This will cause human-readable dates to be localized. 32 | - name: Client-App 33 | in: header 34 | schema: 35 | type: string 36 | example: 'osmcal-widget/1.0.0' 37 | description: Please send some kind of identification of your app, so we can find out easier who you are, if need be. 38 | responses: 39 | 200: 40 | description: List of events (soonest first) 41 | content: 42 | 'application/json': 43 | schema: 44 | $ref: "#/components/schemas/Events" 45 | 46 | /v2/events/past/: 47 | get: 48 | description: Retrieve list of past events 49 | tags: 50 | - Events 51 | parameters: 52 | - name: in 53 | in: query 54 | description: Limit returned events to a given country. The country should be specified as ISO 3166-1 alpha-2 code. 55 | required: false 56 | example: "de" 57 | schema: 58 | type: string 59 | - name: Accept-Language 60 | in: header 61 | schema: 62 | type: string 63 | example: 'pt-BR' 64 | description: The user's locale. This will cause human-readable dates to be localized. 65 | - name: Client-App 66 | in: header 67 | schema: 68 | type: string 69 | example: 'osmcal-widget/1.0.0' 70 | description: Please send some kind of identification of your app, so we can find out easier who you are, if need be. 71 | responses: 72 | 200: 73 | description: List of events (recently passed first) 74 | content: 75 | 'application/json': 76 | schema: 77 | $ref: "#/components/schemas/Events" 78 | 79 | /v1/events/: 80 | get: 81 | deprecated: true 82 | description: Retrieve list of upcoming events 83 | tags: 84 | - Events 85 | parameters: 86 | - name: in 87 | in: query 88 | description: Limit returned events to a given country 89 | required: false 90 | example: "Germany" 91 | schema: 92 | type: string 93 | responses: 94 | 200: 95 | description: List of events (soonest first) 96 | content: 97 | 'application/json': 98 | schema: 99 | $ref: "#/components/schemas/EventsV1" 100 | 101 | /v1/events/past/: 102 | get: 103 | deprecated: true 104 | description: Retrieve list of past events 105 | tags: 106 | - Events 107 | parameters: 108 | - name: in 109 | in: query 110 | description: Limit returned events to a given country 111 | required: false 112 | example: "Germany" 113 | schema: 114 | type: string 115 | responses: 116 | 200: 117 | description: List of events (recently passed first) 118 | content: 119 | 'application/json': 120 | schema: 121 | $ref: "#/components/schemas/EventsV1" 122 | 123 | components: 124 | schemas: 125 | Events: 126 | type: array 127 | items: 128 | type: object 129 | required: 130 | - name 131 | - url 132 | - date 133 | properties: 134 | name: 135 | type: string 136 | example: "Mapping Party #23" 137 | url: 138 | type: string 139 | example: "https://osmcal.org/event/23/" 140 | description: "Link to event on OSMCAL" 141 | date: 142 | $ref: "#/components/schemas/Date" 143 | location: 144 | $ref: "#/components/schemas/Location" 145 | cancelled: 146 | type: boolean 147 | default: false 148 | example: true 149 | description: Indicates whether event has been cancelled. Omited if not. 150 | 151 | Date: 152 | type: object 153 | required: 154 | - start 155 | - human 156 | - human_short 157 | - whole_day 158 | properties: 159 | start: 160 | type: string 161 | example: "2020-05-24T12:00:00+09:00" 162 | description: Start Date as ISO 8601 string 163 | end: 164 | type: string 165 | example: "2020-05-24T14:00:00+09:00" 166 | description: End Date as ISO 8601 string, optional 167 | human: 168 | type: string 169 | example: "24th May 12:00–14:00" 170 | human_short: 171 | type: string 172 | example: "24th May" 173 | description: Only displays the date (range) without time. Useful for e.g. list views. 174 | whole_day: 175 | type: boolean 176 | example: false 177 | description: Date object, with start date, end date (optional), human date (preferred way of display, localized) and indication, whether the event is a full-day event (`whole_day`). 178 | 179 | EventsV1: 180 | type: array 181 | items: 182 | type: object 183 | required: 184 | - name 185 | - url 186 | - date 187 | properties: 188 | name: 189 | type: string 190 | example: "Mapping Party #23" 191 | url: 192 | type: string 193 | example: "https://osmcal.org/event/23/" 194 | description: "Link to event on OSMCAL" 195 | date: 196 | $ref: "#/components/schemas/DateV1" 197 | location: 198 | $ref: "#/components/schemas/Location" 199 | cancelled: 200 | type: boolean 201 | default: false 202 | example: true 203 | description: Indicates whether event has been cancelled. Omited if not. 204 | DateV1: 205 | type: object 206 | required: 207 | - start 208 | properties: 209 | start: 210 | type: string 211 | example: "2020-05-24 12:00:00" 212 | end: 213 | type: string 214 | example: "2020-05-24 14:00:00" 215 | human: 216 | type: string 217 | example: "24th May 12:00–14:00" 218 | whole_day: 219 | type: boolean 220 | example: false 221 | description: Date object, with start date, end date (optional), human date (preferred way of display, localized) and indication, whether the event is a full-day event (`whole_day`). 222 | Location: 223 | type: object 224 | required: 225 | - coords 226 | properties: 227 | short: 228 | type: string 229 | example: "Osaka, Japan" 230 | detailed: 231 | type: string 232 | example: "Tosabori-dori, Chuo, Osaka, Japan" 233 | coords: 234 | type: array 235 | example: [135.5023, 34.6931] 236 | items: 237 | type: number 238 | description: "Coordinates in lon/lat" 239 | venue: 240 | type: string 241 | example: "Cool Pub" 242 | externalDocs: 243 | description: OSMCAL User Documentation 244 | url: https://osmcal.org/documentation/ 245 | -------------------------------------------------------------------------------- /osmcal/api/serializers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | from django.template.loader import render_to_string 5 | from django.urls import reverse 6 | 7 | 8 | class Omitable(object): 9 | """ 10 | This value is empty and the key should be omitted. 11 | If you're thinking: "What is that crazy german guy doing?", well the answer 12 | is simple: I didn't want to pull in the whole of rest framework and hacked 13 | some code. Now it escalated. If you wanna change all of that crap to DRF, 14 | feel free :) 15 | """ 16 | 17 | 18 | class BaseSerializerMany(object): 19 | def __init__(self, objs, context={}): 20 | self.objs = objs 21 | self.context = context 22 | 23 | @property 24 | def json(self): 25 | out = [] 26 | 27 | for obj in self.objs: 28 | oo = {} 29 | for field in self.fields: 30 | try: 31 | v = getattr(self, "attr_" + field)(obj) 32 | if not isinstance(v, Omitable): 33 | oo[field] = v 34 | except AttributeError: 35 | oo[field] = getattr(obj, field) 36 | out.append(oo) 37 | 38 | return json.dumps(out, ensure_ascii=False) 39 | 40 | 41 | class EventsSerializer(BaseSerializerMany): 42 | fields = ("name", "url", "date", "location", "cancelled") 43 | 44 | def attr_date(self, obj): 45 | o = { 46 | "human": render_to_string("osmcal/date.l10n.txt", {"event": obj}).strip(), 47 | "human_short": render_to_string("osmcal/date-short.l10n.txt", {"event": obj}).strip(), 48 | "whole_day": obj.whole_day, 49 | "start": obj.start_localized.isoformat(), 50 | } 51 | if obj.end: 52 | o["end"] = obj.end_localized.isoformat() 53 | return o 54 | 55 | def attr_location(self, obj): 56 | if not obj.location: 57 | return Omitable() 58 | 59 | o = { 60 | "short": obj.location_text, 61 | "detailed": obj.location_detailed_addr, 62 | "coords": [obj.location.x, obj.location.y], 63 | } 64 | 65 | if obj.location_name: 66 | o["venue"] = obj.location_name 67 | return o 68 | 69 | def attr_url(self, obj): 70 | rel_url = reverse("event", args=[obj.id]) 71 | try: 72 | return self.context["request"].build_absolute_uri(rel_url) 73 | except KeyError: 74 | return rel_url 75 | 76 | def attr_cancelled(self, obj): 77 | if not obj.cancelled: 78 | return Omitable() 79 | return obj.cancelled 80 | -------------------------------------------------------------------------------- /osmcal/api/test_views.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.test import Client, TestCase 4 | 5 | 6 | class APIBaseTest(TestCase): 7 | def test_cors(self): 8 | c = Client() 9 | resp = c.options("/api/v1/events/") 10 | 11 | self.assertTrue(resp.has_header("access-control-allow-headers")) 12 | self.assertTrue(resp.get("access-control-allow-origin", "*")) 13 | 14 | def test_past(self): 15 | c = Client() 16 | resp = c.get("/api/v1/events/past/") 17 | self.assertEqual(resp.status_code, 200) 18 | 19 | 20 | class APIV1Test(TestCase): 21 | fixtures = ["demo"] 22 | 23 | def test_structure_v1(self): 24 | """ 25 | This test ensures that the structure of the API response does not change. 26 | """ 27 | c = Client() 28 | resp = c.get("/api/v1/events/") 29 | 30 | evts = resp.json() 31 | self.assertNotEqual(len(evts), 0) 32 | 33 | for evt in evts: 34 | self.assertIn("name", evt) 35 | self.assertIn("url", evt) 36 | self.assertIn("date", evt) 37 | 38 | self.assertIn("start", evt["date"]) 39 | self.assertIn("human", evt["date"]) 40 | self.assertIn("whole_day", evt["date"]) 41 | 42 | datetime.strptime(evt["date"]["start"], "%Y-%m-%d %H:%M:%S") 43 | 44 | if "location" in evt: 45 | self.assertIn("coords", evt["location"]) 46 | 47 | 48 | class APIV2Test(TestCase): 49 | fixtures = ["demo"] 50 | 51 | def test_structure_v2(self): 52 | c = Client() 53 | resp = c.get("/api/v2/events/") 54 | 55 | evts = resp.json() 56 | self.assertNotEqual(len(evts), 0) 57 | 58 | for evt in evts: 59 | self.assertIn("name", evt) 60 | self.assertIn("url", evt) 61 | self.assertIn("date", evt) 62 | 63 | self.assertIn("start", evt["date"]) 64 | self.assertIn("human", evt["date"]) 65 | self.assertIn("whole_day", evt["date"]) 66 | 67 | datetime.fromisoformat(evt["date"]["start"]) 68 | 69 | if "location" in evt: 70 | self.assertIn("coords", evt["location"]) 71 | -------------------------------------------------------------------------------- /osmcal/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import compatviews, views 4 | 5 | app_name = "osmcal.api" 6 | 7 | urlpatterns = [ 8 | path("v1/events/", compatviews.EventListV1.as_view(), name="api-event-list"), 9 | path("v1/events/past/", compatviews.PastEventListV1.as_view(), name="api-past-event-list"), 10 | path("v2/events/", views.EventList.as_view(), name="api-event-list-v2"), 11 | path("v2/events/past/", views.PastEventList.as_view(), name="api-past-event-list-v2"), 12 | path("internal/timezone", views.Timezone.as_view(), name="api-internal-timezone"), 13 | ] 14 | -------------------------------------------------------------------------------- /osmcal/api/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import HttpResponse 3 | from django.utils import timezone 4 | from django.views import View 5 | from osmcal import views 6 | from pytz import timezone as tzp 7 | from timezonefinder import TimezoneFinder 8 | 9 | from . import serializers 10 | from .decorators import ALLOWED_HEADERS, cors_any, language_from_header 11 | 12 | JSON_CONTENT_TYPE = ( 13 | "application/json; charset=" + settings.DEFAULT_CHARSET 14 | ) # This shall be utf-8, otherwise we're not friends anymore. 15 | 16 | tf = TimezoneFinder() 17 | 18 | 19 | class CORSOptionsMixin(object): 20 | def options(self, request, *args, **kwargs): 21 | r = HttpResponse() 22 | r["Access-Control-Allow-Headers"] = ", ".join(ALLOWED_HEADERS) 23 | r["Access-Control-Allow-Origin"] = "*" 24 | return r 25 | 26 | 27 | class EventList(CORSOptionsMixin, views.EventListView): 28 | def get_serializer(self): 29 | return serializers.EventsSerializer 30 | 31 | @cors_any 32 | @language_from_header 33 | def get(self, request, *args, **kwargs): 34 | es = self.get_serializer()(self.get_queryset(request.GET), context={"request": request}) 35 | return HttpResponse(es.json, content_type=JSON_CONTENT_TYPE) 36 | 37 | 38 | class PastEventList(EventList): 39 | RESULT_LIMIT = 20 40 | 41 | def filter_queryset(self, qs, **kwargs): 42 | return qs.filter(start__lte=timezone.now()).order_by("-local_start") 43 | 44 | def get_queryset(self, *args, **kwargs): 45 | return super().get_queryset(*args, **kwargs)[: self.RESULT_LIMIT] 46 | 47 | 48 | class Timezone(View): 49 | def get(self, request, *args, **kwargs): 50 | lat = float(request.GET["lat"]) 51 | lon = float(request.GET["lon"]) 52 | tz = tf.timezone_at(lng=lon, lat=lat) 53 | if tz is None: 54 | return HttpResponse("", status=400) 55 | return HttpResponse(tzp(tz)) 56 | -------------------------------------------------------------------------------- /osmcal/fixtures/demo.yaml: -------------------------------------------------------------------------------- 1 | - model: osmcal.user 2 | fields: 3 | id: 1 4 | name: some_dummy_user 5 | username: osm_1 6 | 7 | - model: osmcal.user 8 | fields: 9 | id: 200 10 | name: Jacques 11 | username: osm_200 12 | 13 | - model: osmcal.user 14 | fields: 15 | id: 300 16 | name: Edith 17 | username: osm_300 18 | 19 | - model: osmcal.user 20 | fields: 21 | id: 400 22 | name: Natasha 23 | username: osm_400 24 | 25 | - model: osmcal.user 26 | fields: 27 | id: 500 28 | name: Karl 29 | username: osm_500 30 | 31 | - model: osmcal.user 32 | fields: 33 | id: 600 34 | name: Linn 35 | username: osm_600 36 | 37 | - model: osmcal.user 38 | fields: 39 | id: 700 40 | name: Kay 41 | username: osm_700 42 | 43 | - model: osmcal.event 44 | pk: 1 45 | fields: 46 | name: "International OSM Day" 47 | start: "2029-05-01 20:00:00" 48 | end: "2029-05-01 22:00:00" 49 | timezone: "Europe/London" 50 | 51 | - model: osmcal.event 52 | pk: 2 53 | fields: 54 | name: みんなで東淀川区の魅力を発信しよう! 55 | start: "2029-08-18 20:00:00" 56 | timezone: "Asia/Tokyo" 57 | whole_day: yes 58 | location_address: 59 | city: Osaka 60 | country: Japan 61 | location: POINT(135.5023 34.6931) 62 | 63 | - model: osmcal.event 64 | pk: 3 65 | fields: 66 | name: "Bonner Stammtisch" 67 | start: "2029-08-20 19:30:00" 68 | end: "2029-08-20 22:00:00" 69 | timezone: "Europe/Berlin" 70 | location_address: 71 | street: Dottendorfer Straße 72 | city: Bonn 73 | state: NRW 74 | country: Germany 75 | location: POINT(7.12115 50.70660) 76 | description: > 77 | Mit einem regelmäßigen Stammtisch kann man die Interessen hier in Bonn und Umgebung bündeln und so die Datenqualität steigern, das Tagging vereinheitlichen und regionale Schwerpunkte neben den individuellen Interessen bilden. 78 | 79 | 80 | Seit 2012 gilt: 81 | 82 | 83 | * jeweils am 3. Dienstag des Monats 84 | 85 | * 19:00 Uhr 86 | 87 | * Beamer: vor Ort verfügbar. 88 | 89 | * Der Tisch soll auf "OSM-Stammtisch" reserviert sein. 90 | 91 | Die **BTHV Club-Gastronomie (Dotty´s vorher Sträter's)** Christian-Miesen-Strasse 1, 53129 Bonn-Dottendorf, Karte) ist mit dem ÖPNV erreichbar: 92 | 93 | - Stadtbahn-Linie 61 und 62 bis Endhaltestelle "Quirinusplatz", ca. 15 Min. Fahrzeit vom HBf > ca. 700 Meter Fußweg entlang der Dottendorfer Straße 94 | 95 | - Stadtbahn-Linie 16, 63, 66 ab Hauptbahnhof oder Linien 16, 63 ab Bad Godesberg bis Haltestelle "Deutsche Telekom/Ollenhauerstraße", jeweils ca. 8-9 Min. Fahrzeit > ca. 12. Min Fußweg entlang der Ollenhauerstraße und der Dottendorfer Straße (ab dem Bahnübergang) 96 | 97 | - Bus: Von Bad Godesberg aus Linie 612 Richtung Dottendorf/Hindenburgplatz, an der Haltestelle "Stephanstraße" aussteigen > ca. 400 Meter Fußweg entlang der Dottendorfer Straße. 98 | 99 | - Die Buslinien 630 und 631 halten etwas günstiger (630: In der Raste, schräg gegenüber, 631: Servatiusstraße, ca. 200 Meter Fußweg) aber verkehren unregelmäßig (ggfs. nur Hinfahrt), sind Querverbindungen und erfordern daher ab Hauptbahnhof resp. Bad Godesberg umsteigen. Bitte beim VRS prüfen, ob es eine passende Verbindung gibt (als Ziel "Christian-Miesen-Straße 1" angeben). 100 | - Fahrrad: Parkplätze vor Ort vorhanden 101 | 102 | - Auto: Parken kann man vor Ort oder in der Christian-Miesen-Straße. 103 | 104 | - model: osmcal.event 105 | pk: 4 106 | fields: 107 | name: "State of the Map" 108 | start: "2029-09-20 08:00:00" 109 | end: "2029-09-24 08:00:00" 110 | timezone: "Europe/Berlin" 111 | whole_day: yes 112 | location: POINT(13.41641 52.52065) 113 | location_address: 114 | street: Alexanderstraße 115 | city: Berlin 116 | country: Germany 117 | 118 | - model: osmcal.event 119 | pk: 5 120 | fields: 121 | name: OSM x Wikidata 122 | start: "2029-08-20 17:00:00" 123 | end: "2029-08-20 19:00:00" 124 | timezone: "Asia/Taipei" 125 | location_address: 126 | housenumber: 4F-A1, No. 106, Sec. 5 127 | street: Xinyi Rd 128 | city: Taipei City 129 | country: Taiwan 130 | 131 | - model: osmcal.event 132 | pk: 6 133 | fields: 134 | name: Multiday with Times 135 | start: "2029-08-20 17:00:00" 136 | end: "2029-08-22 19:00:00" 137 | timezone: "UTC" 138 | 139 | - model: osmcal.eventparticipation 140 | pk: 1 141 | fields: 142 | event_id: 1 143 | user_id: 200 144 | 145 | - model: osmcal.eventparticipation 146 | pk: 2 147 | fields: 148 | event_id: 1 149 | user_id: 300 150 | 151 | - model: osmcal.eventparticipation 152 | pk: 3 153 | fields: 154 | event_id: 1 155 | user_id: 400 156 | 157 | - model: osmcal.eventparticipation 158 | pk: 4 159 | fields: 160 | event_id: 2 161 | user_id: 500 162 | 163 | - model: osmcal.eventparticipation 164 | pk: 5 165 | fields: 166 | event_id: 3 167 | user_id: 200 168 | 169 | - model: osmcal.eventparticipation 170 | pk: 6 171 | fields: 172 | event_id: 3 173 | user_id: 300 174 | 175 | - model: osmcal.eventparticipation 176 | pk: 7 177 | fields: 178 | event_id: 3 179 | user_id: 400 180 | 181 | - model: osmcal.eventparticipation 182 | pk: 8 183 | fields: 184 | event_id: 3 185 | user_id: 500 186 | 187 | - model: osmcal.eventparticipation 188 | pk: 9 189 | fields: 190 | event_id: 3 191 | user_id: 600 192 | 193 | - model: osmcal.eventparticipation 194 | pk: 10 195 | fields: 196 | event_id: 3 197 | user_id: 700 198 | -------------------------------------------------------------------------------- /osmcal/forms.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Iterable 3 | 4 | import babel.dates 5 | import pytz 6 | from django import forms 7 | from django.contrib.postgres.forms import SimpleArrayField 8 | from django.forms import ValidationError 9 | from django.forms.widgets import DateTimeInput, TextInput 10 | 11 | from . import models 12 | from .serializers import JSONEncoder 13 | from .widgets import LeafletWidget, TimezoneWidget 14 | 15 | 16 | class TimezoneField(forms.Field): 17 | def to_python(self, value): 18 | if not value: 19 | # Babel will default to UTC if no string is specified. 20 | return None 21 | 22 | try: 23 | return pytz.timezone(babel.dates.get_timezone_name(value, return_zone=True)) 24 | except pytz.exceptions.Error: 25 | return None 26 | 27 | def validate(self, value): 28 | if not value: 29 | raise ValidationError("no value", code="required") 30 | 31 | 32 | class QuestionForm(forms.ModelForm): 33 | choices = SimpleArrayField(forms.CharField()) 34 | 35 | def clean_choices(self): 36 | return [x for x in self.cleaned_data["choices"][0].splitlines() if x] 37 | 38 | class Meta: 39 | model = models.ParticipationQuestion 40 | fields = ("question_text", "answer_type", "mandatory") 41 | 42 | 43 | class QuestionnaireForm(forms.Form): 44 | def __init__(self, questions: Iterable[models.ParticipationQuestion], **kwargs) -> None: 45 | self.fields: Dict[str, forms.Field] = {} 46 | super().__init__(**kwargs) 47 | for question in questions: 48 | if question.answer_type == "TEXT": 49 | f = forms.CharField(label=question.question_text, required=question.mandatory) 50 | elif question.answer_type == "BOOL": 51 | f = forms.BooleanField(label=question.question_text, required=question.mandatory) 52 | elif question.answer_type == "CHOI": 53 | f = forms.ChoiceField( 54 | label=question.question_text, 55 | required=question.mandatory, 56 | choices=[(x.id, x.text) for x in question.choices.all()], 57 | ) 58 | else: 59 | raise ValueError("invalid answer_type: %s" % (question.answer_type)) 60 | 61 | self.fields[str(question.id)] = f 62 | 63 | def clean(self, *args, **kwargs): 64 | for k in list(self.cleaned_data.keys()): 65 | self.cleaned_data[int(k)] = self.cleaned_data.pop(k) 66 | return super().clean() 67 | 68 | 69 | class EventForm(forms.ModelForm): 70 | timezone = TimezoneField(required=True, widget=TimezoneWidget()) 71 | 72 | class Meta: 73 | model = models.Event 74 | fields = ( 75 | "name", 76 | "whole_day", 77 | "start", 78 | "end", 79 | "link", 80 | "kind", 81 | "location_name", 82 | "location", 83 | "timezone", 84 | "description", 85 | ) 86 | widgets = { 87 | "location": LeafletWidget(), 88 | "start": DateTimeInput(attrs={"class": "datepicker-flat"}), 89 | "end": DateTimeInput(attrs={"class": "datepicker-flat", "placeholder": "optional"}), 90 | "link": TextInput(attrs={"placeholder": "https://"}), 91 | "location_name": TextInput(attrs={"placeholder": "e.g. Café International"}), 92 | } 93 | unlogged_fields = ("timezone",) 94 | 95 | def clean(self, *args, **kwargs): 96 | super().clean(*args, **kwargs) 97 | 98 | if self.errors: 99 | return self.cleaned_data 100 | 101 | tz = self.cleaned_data.get("timezone", None) 102 | 103 | """ 104 | Django automatically assumes that datetimes are in the default time zone (UTC), 105 | but in fact they're in the local time zone, so we're stripping the tzinfo from 106 | the field and setting it to the given time zone. 107 | This does not change the value of the time itself, only the time zone placement. 108 | """ 109 | self.cleaned_data["start"] = tz.localize(self.cleaned_data["start"].replace(tzinfo=None)) 110 | 111 | if self.cleaned_data["end"]: 112 | self.cleaned_data["end"] = tz.localize(self.cleaned_data["end"].replace(tzinfo=None)) 113 | 114 | if self.cleaned_data["end"] <= self.cleaned_data["start"]: 115 | self.add_error("end", "Event end has to be after its start.") 116 | 117 | def to_json(self): 118 | d = {} 119 | for field in self.fields: 120 | if field in self.Meta.unlogged_fields: 121 | continue 122 | d[field] = self.cleaned_data[field] 123 | 124 | return json.loads(json.dumps(d, cls=JSONEncoder)) # This is bad and I should feel bad. 125 | -------------------------------------------------------------------------------- /osmcal/ical.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from textwrap import wrap 3 | from typing import Iterable, List 4 | 5 | from .models import Event 6 | 7 | 8 | def encode_event(evt: Event) -> str: 9 | lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//OSM Calendar"] 10 | lines += event_body(evt) 11 | lines += ["END:VCALENDAR"] 12 | return "\r\n".join(map(line_format, lines)) + "\r\n" 13 | 14 | 15 | def encode_events(evts: Iterable[Event]) -> str: 16 | lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//OSM Calendar"] 17 | for evt in evts: 18 | lines += event_body(evt) 19 | lines += ["END:VCALENDAR"] 20 | return "\r\n".join(map(line_format, lines)) + "\r\n" 21 | 22 | 23 | def line_format(ln: str) -> str: 24 | return "\r\n\t".join(wrap(ln.replace(",", "\,").replace("\n", "\\n"), 72, drop_whitespace=False)) 25 | 26 | 27 | def event_body(evt: Event) -> List[str]: 28 | lines = ["BEGIN:VEVENT"] 29 | 30 | lines.append("UID:OSMCAL-{}".format(evt.id)) 31 | lines.append("DTSTAMP:{:%Y%m%dT%H%M%S}".format(evt.start_localized)) 32 | if evt.cancelled: 33 | lines.append("STATUS:CANCELLED") 34 | 35 | if evt.whole_day: 36 | lines.append("DTSTART;VALUE=DATE:{:%Y%m%d}".format(evt.start_localized)) 37 | if evt.end: 38 | lines.append("DTEND;VALUE=DATE:{:%Y%m%d}".format(evt.end_localized + timedelta(days=1))) 39 | else: 40 | lines.append("DTSTART;TZID={}:{:%Y%m%dT%H%M%S}".format(evt.timezone, evt.start_localized)) 41 | if evt.end: 42 | lines.append("DTEND;TZID={}:{:%Y%m%dT%H%M%S}".format(evt.timezone, evt.end_localized)) 43 | 44 | lines.append("SUMMARY:{}".format(evt.name)) 45 | 46 | description = evt.description 47 | if evt.link: 48 | description += "\n" + "Event Website: " + evt.link 49 | 50 | if description: 51 | lines.append("DESCRIPTION:{}".format(description)) 52 | if evt.location: 53 | lines.append("GEO:{};{}".format(evt.location.x, evt.location.y)) 54 | if evt.location_address: 55 | lines.append("LOCATION:{}".format(evt.location_detailed_addr)) 56 | return lines + ["END:VEVENT"] 57 | -------------------------------------------------------------------------------- /osmcal/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /osmcal/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-05-24 17:06+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: osmcal/templatetags/locadate.py:9 osmcal/templatetags/locadate.py:10 22 | msgid "day_month_format" 23 | msgstr "j. F" 24 | 25 | msgid "day_only_format" 26 | msgstr "j." 27 | -------------------------------------------------------------------------------- /osmcal/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /osmcal/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-05-24 16:23+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: osmcal/templatetags/locadate.py:9 osmcal/templatetags/locadate.py:10 22 | msgid "day_month_format" 23 | msgstr "PLACEHOLDER" # This is not the real value, just a fallback. The real en-value is 'jS F', fallback shall be 'j F'. 24 | 25 | msgid "day_only_format" 26 | msgstr "PLACEHOLDER" 27 | -------------------------------------------------------------------------------- /osmcal/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /osmcal/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-05-24 14:45+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: osmcal/templatetags/locadate.py:9 osmcal/templatetags/locadate.py:10 22 | msgid "day_month_format" 23 | msgstr "j E" 24 | 25 | msgid "day_only_format" 26 | msgstr "j" 27 | -------------------------------------------------------------------------------- /osmcal/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/middlewares/__init__.py -------------------------------------------------------------------------------- /osmcal/middlewares/replay_middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import MiddlewareNotUsed 3 | from django.db.utils import ProgrammingError 4 | from django.http import HttpResponse 5 | from psycopg2.errors import ReadOnlySqlTransaction 6 | 7 | 8 | class ReplayMiddleware: 9 | read_only_exceptions = [ProgrammingError, ReadOnlySqlTransaction] 10 | 11 | def __init__(self, get_response): 12 | if not settings.WRITABLE_REGION: 13 | """ 14 | We're probably running on local or in a non-distributed scenario, 15 | so let's detach this middleware. 16 | """ 17 | raise MiddlewareNotUsed() 18 | 19 | self.get_response = get_response 20 | 21 | def __call__(self, request): 22 | return self.get_response(request) 23 | 24 | def process_exception(self, request, exception): 25 | for roe in self.read_only_exceptions: 26 | if isinstance(exception, roe): 27 | if settings.CURRENT_REGION == settings.WRITABLE_REGION: 28 | # We are already in the region, which supposed to be writable, so reraise: 29 | return None 30 | response = HttpResponse() 31 | self.mark_for_replay(response) 32 | return response 33 | 34 | def mark_for_replay(self, response): 35 | response.headers["fly-replay"] = f"region={settings.WRITABLE_REGION}" 36 | -------------------------------------------------------------------------------- /osmcal/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-10 19:14 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0011_update_proxy_permissions'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='User', 20 | fields=[ 21 | ('password', models.CharField(max_length=128, verbose_name='password')), 22 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 23 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 24 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 25 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 26 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 27 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 28 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 29 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 30 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 31 | ('osm_id', models.IntegerField(primary_key=True, serialize=False)), 32 | ('name', models.CharField(max_length=255)), 33 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 34 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 35 | ], 36 | options={ 37 | 'verbose_name': 'user', 38 | 'verbose_name_plural': 'users', 39 | 'abstract': False, 40 | }, 41 | managers=[ 42 | ('objects', django.contrib.auth.models.UserManager()), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /osmcal/migrations/0002_auto_20190511_0926.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-11 09:26 2 | 3 | from django.conf import settings 4 | import django.contrib.gis.db.models.fields 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import osmcal.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('osmcal', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Event', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=200)), 22 | ('start', models.DateTimeField()), 23 | ('end', models.DateTimeField(blank=True)), 24 | ('location', django.contrib.gis.db.models.fields.PointField(srid=4326)), 25 | ('kind', models.CharField(choices=[(osmcal.models.EventType('Social'), 'Social'), (osmcal.models.EventType('Meeting'), 'Meeting'), (osmcal.models.EventType('Work'), 'Work'), (osmcal.models.EventType('Map Event'), 'Map Event'), (osmcal.models.EventType('Conference'), 'Conference')], max_length=4)), 26 | ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | migrations.AddIndex( 30 | model_name='event', 31 | index=models.Index(fields=['end'], name='osmcal_even_end_4d0a53_idx'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /osmcal/migrations/0003_auto_20190511_0954.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-11 09:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0002_auto_20190511_0926'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='link', 16 | field=models.URLField(null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='event', 20 | name='whole_day', 21 | field=models.BooleanField(default=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /osmcal/migrations/0004_auto_20190512_1620.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-12 16:20 2 | 3 | import django.contrib.gis.db.models.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('osmcal', '0003_auto_20190511_0954'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='event', 16 | name='kind', 17 | field=models.CharField(choices=[('SOCI', 'Social'), ('MEET', 'Meeting'), ('WORK', 'Work'), ('MAPE', 'Map Event'), ('CONF', 'Conference')], max_length=4), 18 | ), 19 | migrations.AlterField( 20 | model_name='event', 21 | name='link', 22 | field=models.URLField(blank=True, null=True), 23 | ), 24 | migrations.AlterField( 25 | model_name='event', 26 | name='location', 27 | field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /osmcal/migrations/0005_event_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-06-07 16:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0004_auto_20190512_1620'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='description', 16 | field=models.TextField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0006_auto_20190731_1340.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.3 on 2019-07-31 13:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0005_event_description'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='event', 15 | name='end', 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0007_event_location_text.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.3 on 2019-07-31 17:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0006_auto_20190731_1340'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='location_text', 16 | field=models.CharField(blank=True, max_length=80, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0008_auto_20190801_0800.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.3 on 2019-08-01 08:00 2 | 3 | import django.contrib.postgres.fields.jsonb 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('osmcal', '0007_event_location_text'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='event', 16 | name='location_text', 17 | ), 18 | migrations.AddField( 19 | model_name='event', 20 | name='location_address', 21 | field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /osmcal/migrations/0009_eventparticipation.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-10-07 18:45 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('osmcal', '0008_auto_20190801_0800'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='EventParticipation', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='osmcal.Event')), 20 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /osmcal/migrations/0010_auto_20191010_1907.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-10-10 19:07 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('osmcal', '0009_eventparticipation'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='eventparticipation', 16 | name='event', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participation', to='osmcal.Event'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /osmcal/migrations/0011_event_location_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-10-15 11:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0010_auto_20191010_1907'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='location_name', 16 | field=models.CharField(blank=True, max_length=50, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0012_auto_20191019_1005.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-10-19 10:05 2 | 3 | from django.conf import settings 4 | import django.contrib.postgres.fields.jsonb 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('osmcal', '0011_event_location_name'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='EventLog', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('data', django.contrib.postgres.fields.jsonb.JSONField()), 21 | ('created_at', models.DateTimeField(auto_now_add=True)), 22 | ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 23 | ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='osmcal.Event')), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /osmcal/migrations/0013_remove_event_created_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-10-19 10:35 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('osmcal', '0012_auto_20191019_1005'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='event', 16 | name='created_by', 17 | ), 18 | migrations.AlterField( 19 | model_name='eventlog', 20 | name='event', 21 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='log', to='osmcal.Event'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /osmcal/migrations/0014_auto_20200201_1440.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-01 14:40 2 | 3 | import django.contrib.postgres.fields.jsonb 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('osmcal', '0013_remove_event_created_by'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='event', 17 | name='description', 18 | field=models.TextField(blank=True, help_text='Tell people what the event is about and what they can expect. You may use Markdown in this field.', null=True), 19 | ), 20 | migrations.CreateModel( 21 | name='ParticipationQuestion', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('question_text', models.CharField(max_length=200)), 25 | ('answer_type', models.CharField(choices=[('TEXT', 'Text Field'), ('CHOI', 'Choice'), ('BOOL', 'Boolean')], max_length=4)), 26 | ('mandatory', models.BooleanField(default=True)), 27 | ('quota', models.PositiveIntegerField(blank=True, null=True)), 28 | ('choices', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), 29 | ('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questions', to='osmcal.Event')), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /osmcal/migrations/0015_auto_20200215_1726.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-15 17:26 2 | 3 | import django.contrib.postgres.fields.jsonb 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('osmcal', '0014_auto_20200201_1440'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='participationquestion', 16 | name='quota', 17 | ), 18 | migrations.AddField( 19 | model_name='eventparticipation', 20 | name='answers', 21 | field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /osmcal/migrations/0016_auto_20200221_1643.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-21 16:43 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('osmcal', '0015_auto_20200215_1726'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='participationquestion', 17 | name='choices', 18 | ), 19 | migrations.CreateModel( 20 | name='ParticipationQuestionChoice', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('text', models.CharField(max_length=200)), 24 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='osmcal.ParticipationQuestion')), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='ParticipationAnswers', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('answer', models.CharField(max_length=200)), 32 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='osmcal.ParticipationQuestion')), 33 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 34 | ], 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /osmcal/migrations/0017_remove_eventparticipation_answers.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-21 16:49 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0016_auto_20200221_1643'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='eventparticipation', 15 | name='answers', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /osmcal/migrations/0018_auto_20200221_1810.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-21 18:10 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('osmcal', '0017_remove_eventparticipation_answers'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='ParticipationAnswer', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('answer', models.CharField(max_length=200)), 20 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='osmcal.ParticipationQuestion')), 21 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 22 | ], 23 | ), 24 | migrations.DeleteModel( 25 | name='ParticipationAnswers', 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /osmcal/migrations/0019_auto_20200222_1056.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-22 10:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0018_auto_20200221_1810'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name='participationanswer', 15 | constraint=models.UniqueConstraint(fields=('question', 'user'), name='unique_question_answer'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /osmcal/migrations/0020_eventparticipation_added_on.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.2 on 2020-02-22 18:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0019_auto_20200222_1056'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='eventparticipation', 15 | name='added_on', 16 | field=models.DateTimeField(null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='eventparticipation', 20 | name='added_on', 21 | field=models.DateTimeField(auto_now_add=True, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /osmcal/migrations/0021_auto_20200223_1808.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-23 18:08 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0020_eventparticipation_added_on'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='participationquestion', 15 | options={'ordering': ('event', 'id')}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='participationquestionchoice', 19 | options={'ordering': ('question', 'id')}, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /osmcal/migrations/0022_event_cancelled.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-16 18:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0021_auto_20200223_1808'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='cancelled', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0023_user_primary_key.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | """ 4 | Hi! I made this because I can't stop making the same mistake: Never use a third-party ID as foreign 5 | key, because every change will be painful. I am fixing this mistake here: We're going to walk over 6 | all tables that reference osmcal_user.osm_id and replace this with a serial id. 7 | This involves a little bit of fiddling, as you might see. 8 | """ 9 | 10 | user_id_dependent_tables = [ 11 | ("osmcal_eventlog", "created_by_id", "osmcal_eventlog_created_by_id_89c62fed_fk_osmcal_user_osm_id"), 12 | ("osmcal_eventparticipation", "user_id", "osmcal_eventparticip_user_id_8a2dfe0f_fk_osmcal_us"), 13 | ("osmcal_participationanswer", "user_id", "osmcal_participation_user_id_93228060_fk_osmcal_us"), 14 | ("osmcal_user_groups", "user_id", "osmcal_user_groups_user_id_c9d0a3d1_fk_osmcal_user_osm_id"), 15 | ("osmcal_user_user_permissions", "user_id", "osmcal_user_user_per_user_id_1ecd1641_fk_osmcal_us"), 16 | ("django_admin_log", "user_id", "django_admin_log_user_id_c564eba6_fk_osmcal_user_osm_id"), 17 | ] 18 | 19 | 20 | def conversion_sql(): 21 | sql = "" 22 | for t in user_id_dependent_tables: 23 | sql += """ 24 | ALTER TABLE {0} DROP CONSTRAINT {2}; 25 | UPDATE {0} SET {1} = (SELECT id FROM osmcal_user WHERE osm_id = {0}.{1}); 26 | ALTER TABLE {0} ADD CONSTRAINT {2} FOREIGN KEY ({1}) REFERENCES osmcal_user (id); 27 | """.format(*t) 28 | return sql 29 | 30 | 31 | class Migration(migrations.Migration): 32 | 33 | dependencies = [ 34 | ('osmcal', '0022_event_cancelled'), 35 | ] 36 | 37 | operations = [ 38 | migrations.SeparateDatabaseAndState( 39 | database_operations=[ 40 | migrations.RunSQL([ 41 | 'ALTER TABLE osmcal_user ADD column id serial UNIQUE;', 42 | conversion_sql(), 43 | 'ALTER TABLE osmcal_user DROP CONSTRAINT osmcal_user_pkey;', 44 | 'ALTER TABLE osmcal_user ADD PRIMARY KEY (id);', 45 | ]) 46 | ], 47 | state_operations=[ 48 | migrations.AddField( 49 | model_name='user', 50 | name='id', 51 | field=models.AutoField(primary_key=True, serialize=False), 52 | preserve_default=False, 53 | ), 54 | migrations.AlterField( 55 | model_name='user', 56 | name='osm_id', 57 | field=models.IntegerField(), 58 | ), 59 | ] 60 | ) 61 | ] 62 | -------------------------------------------------------------------------------- /osmcal/migrations/0024_auto_20200516_1213.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-16 12:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0023_user_primary_key'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='osm_id', 16 | field=models.IntegerField(null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0025_auto_20200917_1515.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-17 15:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0024_auto_20200516_1213'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='event', 15 | name='location_address', 16 | field=models.JSONField(blank=True, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='eventlog', 20 | name='data', 21 | field=models.JSONField(), 22 | ), 23 | migrations.AlterField( 24 | model_name='user', 25 | name='first_name', 26 | field=models.CharField(blank=True, max_length=150, verbose_name='first name'), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /osmcal/migrations/0026_event_timezone.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-28 12:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0025_auto_20200917_1515'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='timezone', 16 | field=models.CharField(blank=True, max_length=100, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0027_timezone_data.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from pytz import timezone 3 | from timezonefinder import TimezoneFinder 4 | 5 | 6 | def set_timezones(apps, schema_editor): 7 | tf = TimezoneFinder() 8 | Event = apps.get_model('osmcal', 'Event') 9 | for event in Event.objects.filter(timezone=None): 10 | if event.location: 11 | tz = tf.timezone_at(lng=event.location.x, lat=event.location.y) 12 | if tz is not None: 13 | event.timezone = tz 14 | if not event.timezone: 15 | # Either time zone could not be determined from location or no location is available. 16 | event.timezone = 'UTC' 17 | tz = timezone(event.timezone) 18 | event.start = tz.localize(event.start.replace(tzinfo=None)) 19 | 20 | if event.end: 21 | event.end = tz.localize(event.end.replace(tzinfo=None)) 22 | event.save() 23 | 24 | 25 | class Migration(migrations.Migration): 26 | 27 | dependencies = [ 28 | ('osmcal', '0026_event_timezone'), 29 | ] 30 | 31 | operations = [ 32 | migrations.RunPython(set_timezones) 33 | ] 34 | -------------------------------------------------------------------------------- /osmcal/migrations/0028_user_home_location.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-12-03 14:00 2 | 3 | import django.contrib.gis.db.models.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('osmcal', '0027_timezone_data'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='user', 16 | name='home_location', 17 | field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /osmcal/migrations/0029_alter_event_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-06 16:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0028_user_home_location'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='event', 15 | name='description', 16 | field=models.TextField(blank=True, help_text='Tell people what the event is about and what they can expect. You may use Markdown in this field.', null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0030_alter_eventparticipation_unique_together.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-20 12:40 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0029_alter_event_description'), 10 | ] 11 | 12 | operations = [ 13 | # This deletes already existing duplicates in the DB: 14 | migrations.RunSQL(""" 15 | DELETE FROM osmcal_eventparticipation WHERE id IN ( 16 | SELECT 17 | p.id 18 | FROM 19 | (SELECT MIN(id) AS min_id, event_id, user_id FROM osmcal_eventparticipation GROUP BY(user_id, event_id) 20 | HAVING COUNT(*) > 1) s, 21 | osmcal_eventparticipation p 22 | WHERE 23 | p.event_id = s.event_id AND 24 | p.user_id = s.user_id AND 25 | p.id != s.min_id 26 | ORDER BY p.added_on 27 | )"""), 28 | 29 | migrations.AlterUniqueTogether( 30 | name='eventparticipation', 31 | unique_together={('event', 'user')}, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /osmcal/migrations/0031_event_hidden.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2023-06-14 16:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0030_alter_eventparticipation_unique_together'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='event', 15 | name='hidden', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0032_user_is_moderator.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.18 on 2023-06-14 17:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0031_event_hidden'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='is_moderator', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/0033_user_is_banned.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2025-02-13 20:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('osmcal', '0032_user_is_moderator'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='user', 15 | name='is_banned', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /osmcal/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/migrations/__init__.py -------------------------------------------------------------------------------- /osmcal/models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import bleach 4 | import markdown 5 | import requests 6 | from background_task import background 7 | from babel.dates import get_timezone_name 8 | from django.contrib.auth.models import AbstractUser 9 | from django.contrib.gis.db.models import PointField 10 | from django.db import models 11 | from django.utils.safestring import mark_safe 12 | from django.utils.text import Truncator 13 | from pytz import timezone 14 | from sentry_sdk import add_breadcrumb 15 | from timezonefinder import TimezoneFinder 16 | 17 | tf = TimezoneFinder() 18 | 19 | 20 | class EventType(Enum): 21 | SOCI = "Social" 22 | MEET = "Meeting" 23 | WORK = "Work" 24 | MAPE = "Map Event" 25 | CONF = "Conference" 26 | 27 | 28 | class Event(models.Model): 29 | name = models.CharField(max_length=200) 30 | 31 | start = models.DateTimeField() 32 | end = models.DateTimeField(blank=True, null=True) 33 | whole_day = models.BooleanField(default=False) 34 | timezone = models.CharField(max_length=100, blank=True, null=True) 35 | 36 | location_name = models.CharField(max_length=50, blank=True, null=True) 37 | location = PointField(blank=True, null=True) 38 | location_address = models.JSONField(blank=True, null=True) 39 | 40 | link = models.URLField(blank=True, null=True) 41 | kind = models.CharField(max_length=4, choices=[(x.name, x.value) for x in EventType]) 42 | description = models.TextField( 43 | blank=True, 44 | null=True, 45 | help_text=mark_safe( 46 | 'Tell people what the event is about and what they can expect. You may use Markdown in this field.' 47 | ), 48 | ) 49 | 50 | cancelled = models.BooleanField(default=False) 51 | hidden = models.BooleanField(default=False) 52 | 53 | def save(self, *args, **kwargs): 54 | if self.location: 55 | self.geocode_location() 56 | 57 | # For the case that an event had previously an address which got removed by the edit: 58 | if not self.location: 59 | self.location_address = None 60 | 61 | super().save(*args, **kwargs) 62 | 63 | def geocode_location(self): 64 | try: 65 | self._geocode_location() 66 | except: 67 | event_id = self.id 68 | self._background_geocode_location(event_id) 69 | 70 | def _geocode_location(self): 71 | nr = requests.get( 72 | "https://nominatim.openstreetmap.org/reverse", 73 | params={"format": "jsonv2", "lat": self.location.y, "lon": self.location.x, "accept-language": "en"}, 74 | headers={"User-Agent": "osmcal"}, 75 | ) 76 | nr.raise_for_status() 77 | 78 | self.location_address = nr.json().get("address", None) 79 | if self.location_address is None: 80 | add_breadcrumb(category="nominatim", level="error", data=nr.json()) 81 | 82 | @staticmethod 83 | @background(schedule=1) 84 | def _background_geocode_location(event_id): 85 | evt = Event.objects.get(id=event_id) 86 | evt.save() # geocodes implicitly 87 | 88 | @property 89 | def location_text(self): 90 | if not self.location_address: 91 | return None 92 | addr = self.location_address 93 | return ", ".join( 94 | filter( 95 | lambda x: x is not None, 96 | [addr.get("village"), addr.get("town"), addr.get("city"), addr.get("state"), addr.get("country")], 97 | ) 98 | ) 99 | 100 | @property 101 | def location_detailed_addr(self): 102 | # TODO: improve 103 | if not self.location_address: 104 | return None 105 | addr = self.location_address 106 | return ", ".join( 107 | filter( 108 | lambda x: x is not None, 109 | [ 110 | self.location_name, 111 | addr.get("house_number"), 112 | addr.get("road"), 113 | addr.get("suburb"), 114 | addr.get("village"), 115 | addr.get("city"), 116 | addr.get("state"), 117 | addr.get("country"), 118 | ], 119 | ) 120 | ) 121 | 122 | @property 123 | def start_localized(self): 124 | tz = timezone(str(self.timezone)) 125 | return self.start.astimezone(tz) 126 | 127 | @property 128 | def end_localized(self): 129 | if not self.end: 130 | return None 131 | tz = timezone(self.timezone) 132 | return self.end.astimezone(tz) 133 | 134 | @property 135 | def tz_name(self): 136 | return get_timezone_name(self.start_localized) 137 | 138 | @property 139 | def year_month(self): 140 | l = self.start_localized 141 | return (l.year, l.month) 142 | 143 | @property 144 | def short_description_without_markup(self) -> str: 145 | if not self.description: 146 | return "" 147 | max_words = 15 148 | cleaned = bleach.clean(markdown.markdown(self.description), tags=[], strip=True) 149 | return Truncator(cleaned).words(max_words) 150 | 151 | @property 152 | def originally_created_by(self) -> "User": 153 | return self.log.order_by("created_at").first().created_by 154 | 155 | class Meta: 156 | indexes = (models.Index(fields=("end",)),) 157 | 158 | 159 | class AnswerType(Enum): 160 | TEXT = "Text Field" 161 | CHOI = "Choice" 162 | BOOL = "Boolean" 163 | 164 | 165 | class ParticipationQuestion(models.Model): 166 | event = models.ForeignKey("Event", null=True, on_delete=models.SET_NULL, related_name="questions") 167 | question_text = models.CharField(max_length=200) 168 | answer_type = models.CharField(max_length=4, choices=[(x.name, x.value) for x in AnswerType]) 169 | mandatory = models.BooleanField(default=True) 170 | 171 | class Meta: 172 | ordering = ("event", "id") 173 | 174 | 175 | class ParticipationQuestionChoice(models.Model): 176 | question = models.ForeignKey(ParticipationQuestion, related_name="choices", on_delete=models.CASCADE) 177 | text = models.CharField(max_length=200) 178 | 179 | class Meta: 180 | ordering = ("question", "id") 181 | 182 | 183 | class EventParticipation(models.Model): 184 | event = models.ForeignKey("Event", null=True, on_delete=models.SET_NULL, related_name="participation") 185 | user = models.ForeignKey("User", null=True, on_delete=models.SET_NULL) 186 | added_on = models.DateTimeField(auto_now_add=True, null=True) 187 | 188 | class Meta: 189 | unique_together = ["event", "user"] 190 | 191 | 192 | class ParticipationAnswer(models.Model): 193 | question = models.ForeignKey(ParticipationQuestion, on_delete=models.CASCADE, related_name="answers") 194 | user = models.ForeignKey("User", null=True, on_delete=models.SET_NULL) 195 | answer = models.CharField(max_length=200) 196 | 197 | class Meta: 198 | constraints = (models.UniqueConstraint(fields=("question", "user"), name="unique_question_answer"),) 199 | 200 | 201 | class EventLog(models.Model): 202 | event = models.ForeignKey("Event", related_name="log", on_delete=models.CASCADE) 203 | data = models.JSONField() 204 | created_by = models.ForeignKey("User", null=True, on_delete=models.SET_NULL) 205 | created_at = models.DateTimeField(auto_now_add=True) 206 | 207 | 208 | class User(AbstractUser): 209 | id = models.AutoField(primary_key=True) 210 | osm_id = models.IntegerField(null=True) 211 | name = models.CharField(max_length=255) 212 | 213 | home_location = PointField(blank=True, null=True) 214 | 215 | is_moderator = models.BooleanField(default=False) 216 | is_banned = models.BooleanField(default=False) 217 | 218 | def home_timezone(self): 219 | if not self.home_location: 220 | return None 221 | return tf.timezone_at(lng=self.home_location.x, lat=self.home_location.y) 222 | 223 | def save(self, *args, **kwargs): 224 | if not self.username: 225 | if self.osm_id: 226 | self.username = "osm_" + str(self.osm_id) 227 | else: 228 | self.username = str(self.id) 229 | super().save(*args, **kwargs) 230 | -------------------------------------------------------------------------------- /osmcal/oauth.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import reverse 3 | from requests_oauthlib import OAuth2Session 4 | 5 | 6 | def get_oauth_session(request): 7 | callback_uri = request.build_absolute_uri(reverse("oauth-callback")) 8 | if settings.DEBUG: 9 | # This simplifies the reverse proxy setup on dev: 10 | # It's pretending we're using HTTPS with a correct configuration. 11 | callback_uri = callback_uri.replace("http", "https") 12 | 13 | return OAuth2Session( 14 | settings.OAUTH2_OPENSTREETMAP_CLIENT_ID, 15 | redirect_uri=callback_uri, 16 | scope=["read_prefs"], 17 | ) 18 | 19 | 20 | def get_authenticated_session(request) -> OAuth2Session: 21 | authorization_response = request.build_absolute_uri(None) 22 | if settings.DEBUG: 23 | # This simplifies the reverse proxy setup on dev: 24 | # It's pretending we're using HTTPS with a correct configuration. 25 | authorization_response = authorization_response.replace("http", "https") 26 | 27 | osm = get_oauth_session(request) 28 | osm.fetch_token( 29 | "https://www.openstreetmap.org/oauth2/token", 30 | client_secret=settings.OAUTH2_OPENSTREETMAP_CLIENT_SECRET, 31 | authorization_response=authorization_response, 32 | ) 33 | return osm 34 | -------------------------------------------------------------------------------- /osmcal/osmuser.py: -------------------------------------------------------------------------------- 1 | from xml.etree import ElementTree as ET 2 | 3 | from django.contrib.gis.geos import Point 4 | from requests_oauthlib import OAuth2Session 5 | 6 | 7 | def get_user_attributes(session: OAuth2Session) -> dict: 8 | userreq = session.get("https://api.openstreetmap.org/api/0.6/user/details") 9 | 10 | userxml = ET.fromstring(userreq.text) 11 | userattrs = userxml.find("user").attrib 12 | 13 | attrs = {"osm_id": userattrs["id"], "display_name": userattrs["display_name"]} 14 | 15 | home = userxml.find("user/home") 16 | if home is not None: 17 | lat = home.attrib.get("lat", None) 18 | lon = home.attrib.get("lon", None) 19 | if lat and lon: 20 | attrs["home_location"] = Point(float(lon), float(lat)) 21 | 22 | return attrs 23 | -------------------------------------------------------------------------------- /osmcal/serializers.py: -------------------------------------------------------------------------------- 1 | from django.core.serializers.json import DjangoJSONEncoder 2 | from django.contrib.gis.geos.point import Point 3 | 4 | 5 | class JSONEncoder(DjangoJSONEncoder): 6 | def default(self, obj): 7 | if isinstance(obj, Point): 8 | return obj.wkt 9 | return super().default(obj) 10 | -------------------------------------------------------------------------------- /osmcal/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 9 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 10 | 11 | SECRET_KEY = os.getenv("OSMCAL_SECRET", "03#2of3$kqqxc&=rz#qkm^+2cl)0al@0k@2c)qx-$rq34m&q55") 12 | DEBUG = os.getenv("OSMCAL_PROD", False) not in ["True", "true", "yes", "1"] 13 | 14 | if DEBUG: 15 | INTERNAL_IPS = ["127.0.0.1"] 16 | 17 | ALLOWED_HOSTS = [os.getenv("OSMCAL_HOST", "*")] 18 | 19 | # Application definition 20 | 21 | INSTALLED_APPS = [ 22 | "django.contrib.admin", 23 | "django.contrib.auth", 24 | "django.contrib.contenttypes", 25 | "django.contrib.sessions", 26 | "django.contrib.messages", 27 | "django.contrib.staticfiles", 28 | "leaflet", 29 | "background_task", 30 | "osmcal", 31 | "osmcal.api", 32 | "osmcal.social", 33 | ] 34 | 35 | MIDDLEWARE = [ 36 | "django.middleware.security.SecurityMiddleware", 37 | "osmcal.middlewares.replay_middleware.ReplayMiddleware", 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.middleware.csrf.CsrfViewMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 44 | ] 45 | 46 | if not DEBUG: 47 | prometheus_dir = os.getenv("prometheus_multiproc_dir", "/tmp/osmcal") 48 | if not os.path.exists(prometheus_dir): 49 | os.makedirs(prometheus_dir, exist_ok=True) 50 | 51 | INSTALLED_APPS.append("django_prometheus") 52 | MIDDLEWARE.insert(0, "django_prometheus.middleware.PrometheusBeforeMiddleware") 53 | MIDDLEWARE.append("django_prometheus.middleware.PrometheusAfterMiddleware") 54 | 55 | ROOT_URLCONF = "osmcal.urls" 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [], 61 | "APP_DIRS": True, 62 | "OPTIONS": { 63 | "context_processors": [ 64 | "django.template.context_processors.debug", 65 | "django.template.context_processors.request", 66 | "django.contrib.auth.context_processors.auth", 67 | "django.contrib.messages.context_processors.messages", 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = "osmcal.wsgi.application" 74 | 75 | WRITABLE_REGION: Optional[str] = os.getenv("WRITABLE_REGION", None) 76 | CURRENT_REGION: Optional[str] = os.getenv("FLY_REGION", None) 77 | 78 | DATABASES = { 79 | "default": { 80 | "HOST": os.getenv("OSMCAL_PG_HOST", ""), 81 | "ENGINE": "django.contrib.gis.db.backends.postgis", 82 | "NAME": os.getenv("OSMCAL_PG_DB", "osmcal"), 83 | "USER": os.getenv("OSMCAL_PG_USER", "osmcal"), 84 | "PASSWORD": os.getenv("OSMCAL_PG_PASSWORD", None), 85 | "PORT": 5432 if WRITABLE_REGION == CURRENT_REGION else 5433, 86 | } 87 | } 88 | 89 | CONN_MAX_AGE = 3600 90 | 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 100 | }, 101 | { 102 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 103 | }, 104 | ] 105 | 106 | LANGUAGE_CODE = "en-us" 107 | TIME_ZONE = "UTC" 108 | USE_I18N = True 109 | USE_L10N = True 110 | USE_TZ = True 111 | 112 | STATIC_URL = "/static/" 113 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 114 | 115 | if not DEBUG: 116 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 117 | 118 | OAUTH_OPENSTREETMAP_KEY = os.getenv("OSMCAL_OSM_KEY", "") 119 | OAUTH_OPENSTREETMAP_SECRET = os.getenv("OSMCAL_OSM_SECRET", "") 120 | 121 | OAUTH2_OPENSTREETMAP_CLIENT_ID = os.getenv("OSMCAL_OAUTH2_CLIENT_ID", "") 122 | OAUTH2_OPENSTREETMAP_CLIENT_SECRET = os.getenv("OSMCAL_OAUTH2_CLIENT_SECRET", "") 123 | 124 | AUTH_USER_MODEL = "osmcal.User" 125 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 126 | 127 | LOGIN_URL = "/login/" 128 | 129 | if not DEBUG: 130 | import sentry_sdk 131 | from sentry_sdk.integrations.django import DjangoIntegration 132 | 133 | sentry_sdk.init( 134 | dsn=os.getenv("OSMCAL_SENTRY_URL"), 135 | integrations=[DjangoIntegration()], 136 | traces_sample_rate=0.01, 137 | ) 138 | 139 | LEAFLET_CONFIG = {"RESET_VIEW": False, "MAX_ZOOM": 19, "ATTRIBUTION_PREFIX": False} 140 | 141 | SOCIAL = { 142 | "mastodon": {"access_token": os.getenv("MASTODON_ACCESS_TOKEN", None)}, 143 | } 144 | 145 | if gdal_path := os.getenv("GDAL_LIBRARY_PATH", None): 146 | GDAL_LIBRARY_PATH = gdal_path 147 | 148 | if geos_path := os.getenv("GEOS_LIBRARY_PATH", None): 149 | GEOS_LIBRARY_PATH = geos_path 150 | -------------------------------------------------------------------------------- /osmcal/social/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "osmcal.social.apps.SocialConfig" 2 | -------------------------------------------------------------------------------- /osmcal/social/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.db.models.signals import post_save 3 | 4 | 5 | class SocialConfig(AppConfig): 6 | name = "osmcal.social" 7 | 8 | def ready(self): 9 | from .post import announce_event 10 | 11 | post_save.connect(announce_event, sender="osmcal.Event") 12 | -------------------------------------------------------------------------------- /osmcal/social/management/commands/twitterkey.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from osmcal.social import twitter 3 | 4 | 5 | class Command(BaseCommand): 6 | def add_arguments(self, parser): 7 | parser.add_argument("--verifier", type=str) 8 | 9 | def handle(self, *args, **options): 10 | if options.get("verifier", None): 11 | twitter.auth_keys(options["verifier"]) 12 | else: 13 | twitter.auth_initialize() 14 | -------------------------------------------------------------------------------- /osmcal/social/mastodon.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from django.conf import settings 3 | 4 | post_status_url = "https://en.osm.town/api/v1/statuses" 5 | 6 | 7 | def _get_config_value(key, default=None): 8 | return settings.SOCIAL["mastodon"].get(key, None) 9 | 10 | 11 | def post(text: str): 12 | resp = requests.post( 13 | post_status_url, 14 | headers={"Authorization": f"Bearer {_get_config_value('access_token')}"}, 15 | data={"status": text}, 16 | ) 17 | resp.raise_for_status() 18 | -------------------------------------------------------------------------------- /osmcal/social/post.py: -------------------------------------------------------------------------------- 1 | from background_task import background 2 | from osmcal.models import Event 3 | from osmcal.social import mastodon 4 | from osmcal.templatetags import locadate 5 | 6 | 7 | def assemble_msg(evt_id): 8 | evt = Event.objects.get(id=evt_id) 9 | 10 | return "{} on {} https://osmcal.org/event/{}/".format(evt.name, locadate.short_date_format(evt), evt.id) 11 | 12 | 13 | @background(schedule=5) 14 | def announce_event_now(evt_id): 15 | msg = assemble_msg(evt_id) 16 | mastodon.post(msg) 17 | 18 | 19 | def announce_event(instance, created, **kwargs): 20 | if not created: 21 | return 22 | 23 | announce_event_now(instance.id) 24 | -------------------------------------------------------------------------------- /osmcal/static/osmcal/event-form.css: -------------------------------------------------------------------------------- 1 | .event-form { 2 | margin: 0 auto; 3 | max-width: 600px; 4 | } 5 | 6 | .event-form-line { 7 | margin-bottom: 1em; 8 | } 9 | 10 | .event-form-leading-label { 11 | box-sizing: border-box; 12 | color: #5A5A5A; 13 | display: block; 14 | font-weight: bold; 15 | padding-right: 10px; 16 | } 17 | 18 | .event-form input, select { 19 | border: 1px solid #AAAAAA; 20 | box-sizing: border-box; 21 | font-size: 12pt; 22 | padding: 10px; 23 | width: 100%; 24 | } 25 | 26 | .event-form .event-form-input { 27 | width: 100%; 28 | } 29 | 30 | .event-form input[type="checkbox"] { 31 | width: inherit; 32 | } 33 | 34 | .event-form-input-info { 35 | font-size: 90%; 36 | color: #666666; 37 | } 38 | 39 | .event-form textarea { 40 | font-size: 12pt; 41 | width: 100%; 42 | } 43 | 44 | .event-form-helptext { 45 | color: #666666; 46 | display: block; 47 | font-size: 90%; 48 | margin-top: .25em; 49 | } 50 | 51 | .event-form #id_location-map { 52 | display: inline-block; 53 | margin-bottom: 1em; 54 | vertical-align: top; 55 | width: 100%; 56 | } 57 | 58 | .event-form .submit-row input { 59 | width: 93%; 60 | } 61 | 62 | .leaflet-control-geocoder-form input { 63 | font-size: 12px; 64 | height: 100%; 65 | margin-bottom: inherit; 66 | width: inherit; 67 | } 68 | 69 | .event-form-error { 70 | background-color: #ffe0bb; 71 | } 72 | 73 | .event-question-form { 74 | margin: 0 auto; 75 | max-width: 600px; 76 | } 77 | 78 | .event-question-form-label { 79 | color: #666666; 80 | font-weight: bold; 81 | } 82 | 83 | @media only screen and (max-width: 600px) { 84 | .event-question-form-label { 85 | display: none; 86 | } 87 | } 88 | 89 | .event-question-form-block { 90 | display: inline-block; 91 | width: 100%; 92 | } 93 | 94 | .event-question-form-block + .event-question-form-block { 95 | margin-top: 3em; 96 | } 97 | 98 | .event-question-form-block h3 { 99 | margin-block-start: 0; 100 | } 101 | 102 | .event-question-form-helptext { 103 | color: #666666; 104 | display: block; 105 | font-size: 90%; 106 | margin-top: .25em; 107 | padding-left: 3px; 108 | } 109 | 110 | .event-question-input-column { 111 | width: 100%; 112 | } 113 | 114 | .event-question-label { 115 | color: #666666; 116 | display: block; 117 | font-weight: bold; 118 | } 119 | 120 | .event-question-inline-label { 121 | color: #666666; 122 | display: inline-block; 123 | font-weight: bold; 124 | } 125 | 126 | .event-question-line { 127 | align-items: baseline; 128 | display: flex; 129 | } 130 | 131 | .event-question-line + .event-question-line { 132 | margin-top: 1em; 133 | } 134 | 135 | .event-question-label { 136 | display: inline-block; 137 | flex-basis: auto; 138 | padding-right: 10px; 139 | text-align: right; 140 | -webkit-user-select: none; 141 | -khtml-user-select: none; 142 | -moz-user-select: none; 143 | -ms-user-select: none; 144 | user-select: none; 145 | width: 150px; 146 | } 147 | 148 | .event-question-input { 149 | flex: 1; 150 | font-size: 12pt; 151 | padding: 10px; 152 | } 153 | 154 | .event-question-choices { 155 | list-style-type: none; 156 | margin: 0; 157 | padding: 0; 158 | } 159 | 160 | .event-question-choice-button { 161 | align-self: flex-end; 162 | flex: 1; 163 | margin-left: .5em; 164 | } 165 | -------------------------------------------------------------------------------- /osmcal/static/osmcal/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/favicon.png -------------------------------------------------------------------------------- /osmcal/static/osmcal/osm_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/osm_logo.png -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SIL Open Font License.txt: -------------------------------------------------------------------------------- 1 | Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL 5 | 6 | ----------------------------------------------------------- 7 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 8 | ----------------------------------------------------------- 9 | 10 | PREAMBLE 11 | The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. 12 | 13 | The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. 14 | 15 | DEFINITIONS 16 | "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. 17 | 18 | "Reserved Font Name" refers to any names specified as such after the copyright statement(s). 19 | 20 | "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). 21 | 22 | "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. 23 | 24 | "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. 25 | 26 | PERMISSION & CONDITIONS 27 | Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 28 | 29 | 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 30 | 31 | 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 32 | 33 | 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 34 | 35 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 36 | 37 | 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. 38 | 39 | TERMINATION 40 | This license becomes null and void if any of the above conditions are not met. 41 | 42 | DISCLAIMER 43 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-Black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-Black.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-BlackIt.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-BlackIt.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-Bold.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-BoldIt.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-BoldIt.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-ExtraLight.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-ExtraLight.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-ExtraLightIt.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-ExtraLightIt.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-It.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-It.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-Light.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-LightIt.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-LightIt.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-Regular.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-Semibold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-Semibold.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/source-sans-pro/SourceSansPro-SemiboldIt.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/source-sans-pro/SourceSansPro-SemiboldIt.otf -------------------------------------------------------------------------------- /osmcal/static/osmcal/style-mobile.css: -------------------------------------------------------------------------------- 1 | .content-main { 2 | margin-left: 1em; 3 | margin-right: 1em; 4 | } 5 | 6 | .content-main-fullwidth { 7 | margin-left: 0; 8 | margin-right: 0; 9 | } 10 | 11 | .event-single-block { 12 | display: block; 13 | } 14 | 15 | .hide-slim { 16 | display: none; 17 | } 18 | 19 | .hide-wide { 20 | display: inherit; 21 | } 22 | 23 | .event-single-attribution { 24 | margin-top: 2em; 25 | } 26 | -------------------------------------------------------------------------------- /osmcal/static/osmcal/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-display: swap; 3 | font-family: "Source Sans"; 4 | font-weight: normal; 5 | src: url("/static/osmcal/source-sans-pro/SourceSansPro-Regular.otf"); 6 | } 7 | 8 | @font-face { 9 | font-display: swap; 10 | font-family: "Source Sans"; 11 | font-weight: bold; 12 | src: url("/static/osmcal/source-sans-pro/SourceSansPro-Bold.otf"); 13 | } 14 | 15 | body { 16 | font-family: "Source Sans Pro", "Source Sans", sans-serif; /* if Source Sans Pro is installed, it won't be loaded from remote */ 17 | margin: 0; 18 | } 19 | 20 | a { 21 | color: #24d; 22 | text-decoration: none; 23 | } 24 | 25 | a:hover { 26 | text-decoration: underline; 27 | } 28 | 29 | h1, h2 { 30 | color: #5A5A5A; 31 | } 32 | 33 | .btn { 34 | background-color: white; 35 | border: 2px solid #7EBC6F; 36 | color: #7EBC6F; 37 | cursor: pointer; 38 | font-size: 12pt; 39 | font-weight: bold; 40 | padding: 0.5em 1.2em; 41 | } 42 | 43 | .btn-mini { 44 | background-color: white; 45 | border: 2px solid #7EBC6F; 46 | color: #7EBC6F; 47 | cursor: pointer; 48 | font-size: 10pt; 49 | font-weight: bold; 50 | padding: 0.3em 0.5em; 51 | } 52 | 53 | .btn-negative { 54 | border-color: #DD9D53; 55 | color: #DD9D53; 56 | } 57 | 58 | .hide-wide { 59 | display: none; 60 | } 61 | 62 | .hide-slim { 63 | display: inherit; 64 | } 65 | 66 | .content-main { 67 | display: block; 68 | } 69 | 70 | .content-main-fullwidth { 71 | margin-left: 1em; 72 | } 73 | 74 | .header-bar { 75 | background-color: #F3F3F3; 76 | display: block; 77 | height: 55px; 78 | margin-bottom: 1em; 79 | padding: 10px; 80 | } 81 | 82 | .header-home-area, .header-home-area:hover { 83 | color: #5A5A5A; 84 | display: inline-block; 85 | height: 100%; 86 | font-size: 18pt; 87 | text-decoration: none; 88 | } 89 | 90 | .header-home-icon { 91 | margin-right: 5px; 92 | vertical-align: middle; 93 | } 94 | 95 | .navigation-top { 96 | display: block; 97 | float: right; 98 | height: 100%; 99 | } 100 | 101 | .navigation-top a { 102 | vertical-align: middle; 103 | } 104 | 105 | .event-list { 106 | list-style: none; 107 | margin: 0 auto; 108 | max-width: 600px; 109 | padding-left: 0; 110 | } 111 | 112 | .event-list-group { 113 | list-style: none; 114 | padding-left: 0; 115 | } 116 | 117 | .event-list-group-title { 118 | font-size: 24pt; 119 | margin-bottom: 0.25em; 120 | color: #5A5A5A; 121 | } 122 | 123 | .event-list a:hover { 124 | text-decoration: none; 125 | } 126 | 127 | .event-list-entry:first-child { 128 | border-top: 1px solid #DBDBDB; 129 | } 130 | 131 | .event-list-entry { 132 | border-bottom: 1px solid #DBDBDB; 133 | color: #5A5A5A; 134 | font-size: 14pt; 135 | padding: 1em 0; 136 | overflow: hidden; 137 | } 138 | 139 | .event-list-entry a { 140 | color: inherit; 141 | } 142 | 143 | .event-list-entry-cancelled { 144 | opacity: 0.7; 145 | text-decoration: line-through; 146 | } 147 | 148 | .event-list-entry-box { 149 | align-items: center; 150 | display: flex; 151 | } 152 | 153 | .event-entry-main { 154 | padding-right: 1em; 155 | min-width: 0; 156 | width: 100%; 157 | } 158 | 159 | .event-entry-name { 160 | font-size: 20pt; 161 | margin: 0; 162 | word-wrap: break-word; 163 | } 164 | 165 | .event-entry-location { 166 | color: #A4A4A4; 167 | font-size: 12pt; 168 | margin: 0; 169 | } 170 | 171 | .event-entry-date { 172 | text-align: right; 173 | white-space: nowrap; 174 | } 175 | 176 | .footer { 177 | background-color: #F3F3F3; 178 | font-size: 90%; 179 | margin-top: 2em; 180 | padding: 1em; 181 | } 182 | 183 | .select-dropdown { 184 | -moz-appearance: none; 185 | -webkit-appearance: none; 186 | background: url('/static/osmcal/triangle.svg') no-repeat right white; 187 | background-origin: content-box; 188 | border: 2px solid #7EBC6F; 189 | border-radius: 0; 190 | color: #7EBC6F; 191 | font-size: 12pt; 192 | font-weight: bold; 193 | min-width: 9em; 194 | padding: 0.5em 1em; 195 | width: 100%; 196 | } 197 | 198 | .event-single-title { 199 | line-height: 1em; 200 | margin: 0; 201 | } 202 | 203 | .event-single-title-cancelled { 204 | text-decoration: line-through; 205 | } 206 | 207 | .event-single-location { 208 | color: #5A5A5A; 209 | margin: 0; 210 | } 211 | 212 | .event-single-block { 213 | display: flex; 214 | } 215 | 216 | .event-single-main { 217 | max-width: 600px; 218 | overflow-wrap: break-word; 219 | word-wrap: break-word; 220 | } 221 | 222 | .event-single-main pre { 223 | overflow: scroll; 224 | } 225 | 226 | .event-single-main, .event-single-additional { 227 | flex: 1; 228 | padding-right: 1em; 229 | } 230 | 231 | .event-single-additional .leaflet-container { 232 | height: 400px; 233 | } 234 | 235 | .event-single-side-buttons { 236 | margin-top: 1em; 237 | text-align: right; 238 | } 239 | 240 | .event-single-date { 241 | font-size: 110%; 242 | margin: 1em 0; 243 | } 244 | 245 | .text { 246 | display: block; 247 | margin: auto; 248 | max-width: 600px; 249 | } 250 | 251 | .text pre { 252 | overflow: scroll; 253 | } 254 | 255 | .event-single-join { 256 | margin-top: .5em; 257 | } 258 | 259 | .event-single-participants h2 { 260 | margin-bottom: .25em; 261 | } 262 | 263 | .event-single-participants { 264 | margin-top: 1em; 265 | } 266 | 267 | .event-feed-button-panel { 268 | display: flex; 269 | justify-content: space-between; 270 | margin-bottom: 1em; 271 | } 272 | 273 | .event-filter-controls { 274 | padding-right: 10px; 275 | } 276 | 277 | .participant-table { 278 | margin-top: 1em; 279 | } 280 | 281 | .participant-table th { 282 | text-align: left; 283 | } 284 | 285 | .participant-table td, .participant-table th { 286 | padding: 0.5em; 287 | } 288 | 289 | .participant-table tr:nth-child(even) { 290 | background-color: #eeeeee; 291 | } 292 | 293 | .code-comment { 294 | opacity: 0.5; 295 | } 296 | 297 | .code-number { 298 | color: #4e7843; 299 | } 300 | 301 | .code-bool { 302 | color: #9b692d; 303 | } 304 | 305 | .form-inline { 306 | display: inline-block; 307 | } 308 | -------------------------------------------------------------------------------- /osmcal/static/osmcal/survey-form.css: -------------------------------------------------------------------------------- 1 | .survey-form-line { 2 | align-items: center; 3 | display: flex; 4 | margin-top: 1em; 5 | max-width: 600px; 6 | } 7 | 8 | .survey-form-leading-label { 9 | font-weight: bold; 10 | padding-right: 10px; 11 | text-align: right; 12 | width: 150px; 13 | } 14 | 15 | .survey-form-line input, .survey-form-line select { 16 | font-size: 14pt; 17 | padding: 0.5em; 18 | } 19 | 20 | .survey-form-line select, .survey-form-line input[type='text'] { 21 | flex: 1; 22 | } 23 | 24 | .survey-submit-button { 25 | margin-top: 1em; 26 | max-width: 600px; 27 | width: 100%; 28 | } 29 | 30 | .survey-form-required { 31 | color: red; 32 | } 33 | -------------------------------------------------------------------------------- /osmcal/static/osmcal/thirdparty/Control.OSMGeocoder.css: -------------------------------------------------------------------------------- 1 | .leaflet-control-geocoder a { 2 | background-position: 50% 50%; 3 | background-repeat: no-repeat; 4 | display: block; 5 | } 6 | 7 | .leaflet-control-geocoder { 8 | background: #f8f8f9; 9 | -moz-border-radius: 8px; 10 | -webkit-border-radius: 8px; 11 | border-radius: 8px; 12 | } 13 | 14 | .leaflet-control-geocoder a { 15 | width: 36px; 16 | height: 36px; 17 | } 18 | 19 | .leaflet-touch .leaflet-control-geocoder a { 20 | width: 44px; 21 | height: 44px; 22 | } 23 | 24 | .leaflet-control-geocoder .leaflet-control-geocoder-form, 25 | .leaflet-control-geocoder-expanded .leaflet-control-geocoder-toggle { 26 | display: none; 27 | } 28 | 29 | .leaflet-control-geocoder-expanded .leaflet-control-geocoder-form { 30 | display: block; 31 | position: relative; 32 | } 33 | 34 | .leaflet-control-geocoder-expanded .leaflet-control-geocoder-form { 35 | background-color: white; 36 | border: 2px solid rgba(0,0,0,0.2); 37 | border-radius: 8px; 38 | padding: 5px; 39 | } 40 | -------------------------------------------------------------------------------- /osmcal/static/osmcal/thirdparty/Control.OSMGeocoder.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2012 sa3m (https://github.com/sa3m) 2 | // All rights reserved. 3 | 4 | // Redistribution and use in source and binary forms, with or without modification, 5 | // are permitted provided that the following conditions are met: 6 | 7 | // Redistributions of source code must retain the above copyright notice, this 8 | // list of conditions and the following disclaimer. 9 | 10 | // Redistributions in binary form must reproduce the above copyright notice, this 11 | // list of conditions and the following disclaimer in the documentation and/or 12 | // other materials provided with the distribution. 13 | 14 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | // ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | // ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | if (typeof console == "undefined") { 26 | this.console = { log: function (msg) { /* do nothing since it would otherwise break IE */} }; 27 | } 28 | 29 | 30 | L.Control.OSMGeocoder = L.Control.extend({ 31 | options: { 32 | collapsed: true, 33 | position: 'topright', 34 | text: 'Locate', 35 | placeholder: '', 36 | bounds: null, // L.LatLngBounds 37 | email: null, // String 38 | callback: function (results) { 39 | if (results.length == 0) { 40 | console.log("ERROR: didn't find a result"); 41 | return; 42 | } 43 | var bbox = results[0].boundingbox, 44 | first = new L.LatLng(bbox[0], bbox[2]), 45 | second = new L.LatLng(bbox[1], bbox[3]), 46 | bounds = new L.LatLngBounds([first, second]); 47 | this._map.fitBounds(bounds); 48 | } 49 | }, 50 | 51 | _callbackId: 0, 52 | 53 | initialize: function (options) { 54 | L.Util.setOptions(this, options); 55 | }, 56 | 57 | onAdd: function (map) { 58 | this._map = map; 59 | 60 | var className = 'leaflet-control-geocoder', 61 | container = this._container = L.DomUtil.create('div', className); 62 | 63 | L.DomEvent.disableClickPropagation(container); 64 | 65 | var form = this._form = L.DomUtil.create('form', className + '-form'); 66 | 67 | var input = this._input = document.createElement('input'); 68 | input.type = "text"; 69 | input.placeholder = this.options.placeholder || ''; 70 | 71 | var submit = document.createElement('input'); 72 | submit.type = "submit"; 73 | submit.value = this.options.text; 74 | 75 | form.appendChild(input); 76 | form.appendChild(submit); 77 | 78 | L.DomEvent.addListener(form, 'submit', this._geocode, this); 79 | 80 | if (this.options.collapsed) { 81 | L.DomEvent.addListener(container, 'mouseover', this._expand, this); 82 | L.DomEvent.addListener(container, 'mouseout', this._collapse, this); 83 | 84 | var link = this._layersLink = L.DomUtil.create('a', className + '-toggle', container); 85 | link.href = '#'; 86 | link.title = 'Nominatim Geocoder'; 87 | 88 | L.DomEvent.addListener(link, L.Browser.touch ? 'click' : 'focus', this._expand, this); 89 | 90 | this._map.on('movestart', this._collapse, this); 91 | } else { 92 | this._expand(); 93 | } 94 | 95 | container.appendChild(form); 96 | 97 | return container; 98 | }, 99 | 100 | /* helper functions for cordinate extraction */ 101 | _createSearchResult : function(lat, lon) { 102 | //creates an position description similar to the result of a Nominatim search 103 | var diff = 0.005; 104 | var result = []; 105 | result[0] = {}; 106 | result[0]["boundingbox"] = [parseFloat(lat)-diff,parseFloat(lat)+diff,parseFloat(lon)-diff,parseFloat(lon)+diff]; 107 | result[0]["class"]="boundary"; 108 | result[0]["display_name"]="Position: "+lat+" "+lon; 109 | result[0]["lat"] = lat; 110 | result[0]["lon"] = lon; 111 | return result; 112 | }, 113 | _isLatLon : function (q) { 114 | //"lon lat" => xx.xxx x.xxxxx 115 | var re = /(-?\d+\.\d+)\s(-?\d+\.\d+)/; 116 | var m = re.exec(q); 117 | if (m != undefined) return m; 118 | 119 | //lat...xx.xxx...lon...x.xxxxx 120 | re = /lat\D*(-?\d+\.\d+)\D*lon\D*(-?\d+\.\d+)/; 121 | m = re.exec(q); 122 | //showRegExpResult(m); 123 | if (m != undefined) return m; 124 | else return null; 125 | }, 126 | _isLatLon_decMin : function (q) { 127 | //N 53° 13.785' E 010° 23.887' 128 | //re = /[NS]\s*(\d+)\D*(\d+\.\d+).?\s*[EW]\s*(\d+)\D*(\d+\.\d+)\D*/; 129 | re = /([ns])\s*(\d+)\D*(\d+\.\d+).?\s*([ew])\s*(\d+)\D*(\d+\.\d+)/i; 130 | m = re.exec(q.toLowerCase()); 131 | //showRegExpResult(m); 132 | if ((m != undefined)) return m; 133 | else return null; 134 | // +- dec min +- dec min 135 | }, 136 | 137 | _geocode : function (event) { 138 | L.DomEvent.preventDefault(event); 139 | var q = this._input.value; 140 | //try to find corrdinates 141 | if (this._isLatLon(q) != null) 142 | { 143 | var m = this._isLatLon(q); 144 | //m = {lon, lat} 145 | this.options.callback.call(this, this._createSearchResult(m[1],m[2])); 146 | return; 147 | } 148 | else if (this._isLatLon_decMin(q) != null) 149 | { 150 | var m = this._isLatLon_decMin(q); 151 | //m: [ns, lat dec, lat min, ew, lon dec, lon min] 152 | var temp = new Array(); 153 | temp['n'] = 1; 154 | temp['s'] = -1; 155 | temp['e'] = 1; 156 | temp['w'] = -1; 157 | this.options.callback.call(this,this._createSearchResult( 158 | temp[m[1]]*(Number(m[2]) + m[3]/60), 159 | temp[m[4]]*(Number(m[5]) + m[6]/60) 160 | )); 161 | return; 162 | } 163 | 164 | //and now Nominatim 165 | //http://wiki.openstreetmap.org/wiki/Nominatim 166 | window[("_l_osmgeocoder_"+this._callbackId)] = L.Util.bind(this.options.callback, this); 167 | 168 | 169 | /* Set up params to send to Nominatim */ 170 | var params = { 171 | // Defaults 172 | q: this._input.value, 173 | json_callback : ("_l_osmgeocoder_"+this._callbackId++), 174 | format: 'json' 175 | }; 176 | 177 | if (this.options.bounds && this.options.bounds != null) { 178 | if( this.options.bounds instanceof L.LatLngBounds ) { 179 | params.viewbox = this.options.bounds.toBBoxString(); 180 | params.bounded = 1; 181 | } 182 | else { 183 | console.log('bounds must be of type L.LatLngBounds'); 184 | return; 185 | } 186 | } 187 | 188 | if (this.options.email && this.options.email != null) { 189 | if (typeof this.options.email == 'string') { 190 | params.email = this.options.email; 191 | } 192 | else{ 193 | console.log('email must be a string'); 194 | } 195 | } 196 | 197 | var protocol = location.protocol; 198 | if (protocol == "file:") protocol = "https:"; 199 | var url = protocol + "//nominatim.openstreetmap.org/search" + L.Util.getParamString(params), 200 | script = document.createElement("script"); 201 | 202 | 203 | 204 | 205 | script.type = "text/javascript"; 206 | script.src = url; 207 | script.id = this._callbackId; 208 | document.getElementsByTagName("head")[0].appendChild(script); 209 | }, 210 | 211 | _expand: function () { 212 | L.DomUtil.addClass(this._container, 'leaflet-control-geocoder-expanded'); 213 | }, 214 | 215 | _collapse: function () { 216 | this._container.className = this._container.className.replace(' leaflet-control-geocoder-expanded', ''); 217 | } 218 | }); 219 | -------------------------------------------------------------------------------- /osmcal/static/osmcal/touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/static/osmcal/touch-icon.png -------------------------------------------------------------------------------- /osmcal/static/osmcal/triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /osmcal/templates/400.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Bad Request

6 |

The request you have formulated is invalid. If you need assistance, please contact us.

7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /osmcal/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Page not found.

6 |

Unfortunately this resource does not exist (anymore).

7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /osmcal/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Internal Error

6 |

Something went wrong, we recorded an error and will try to fix the issue as soon as possible.

7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /osmcal/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | {% block title %}OpenStreetMap Calendar{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% block head %} 17 | {% endblock %} 18 | 19 | 20 |
21 | 22 | 23 | OpenStreetMap Calendar 24 | Calendar 25 | 26 | {% if request.resolver_match.view_name not in "event,event-edit" %} 27 | 30 | {% endif %} 31 |
32 | 33 | {% block content %} 34 | {% endblock %} 35 | 36 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/date-base.txt: -------------------------------------------------------------------------------- 1 | {% now "Y" as current_year %}{% load tz tabless %}{% timezone event.timezone %}{% tabless %} 2 | {{ event.start|date:day_fmt }} 3 | {% if event.start|date:"Y" != current_year %} {{ event.start|date:"Y" }}{% endif %} 4 | {% if not event.whole_day %} 5 | {{ event.start|date:"G:i" }} 6 | {% endif %} 7 | {% if event.end and event.start|date:"jFY" == event.end|date:"jFY" %} – {{ event.end|date:"G:i" }} 8 | {% elif event.end %} 9 | – {{ event.end|date:day_fmt }}{% if event.end|date:"Y" != current_year %} {{ event.end|date:"Y" }}{% endif %} 10 | {% if not event.whole_day %} 11 | {{ event.end|date:"G:i" }} 12 | {% endif %} 13 | {% endif %} 14 | {% if not event.location %} ({{ event.timezone }}){% endif %} 15 | {% endtabless %}{% endtimezone %} 16 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/date-short-base.txt: -------------------------------------------------------------------------------- 1 | {% with start=event.start_localized %}{% with end=event.end_localized %}{% load tz %}{% timezone event.timezone %} 2 | {% if start.day == end.day or not end %} 3 | {{ start|date:day_mon_fmt }} 4 | {% elif start.month == end.month %} 5 | {{ start|date:day_fmt }}–{{ end|date:day_mon_fmt }} 6 | {% else %} 7 | {{ start|date:day_mon_fmt }}–{{ end|date:day_mon_fmt }} 8 | {% endif %} 9 | {% endtimezone %}{% endwith %}{% endwith %} 10 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/date-short.l10n.txt: -------------------------------------------------------------------------------- 1 | {% load locadate i18n %}{% get_current_language as lang %}{% loca_day_month_fmt lang as day_mon_fmt %}{% loca_day_fmt lang as day_fmt %}{% include 'osmcal/date-short-base.txt' %} 2 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/date-short.txt: -------------------------------------------------------------------------------- 1 | {% with "jS F" as day_mon_fmt %}{% with "jS" as day_fmt %}{% include 'osmcal/date-short-base.txt' %}{% endwith %}{% endwith %} 2 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/date.l10n.txt: -------------------------------------------------------------------------------- 1 | {% load locadate i18n %}{% get_current_language as lang %}{% loca_day_month_fmt lang as day_fmt %} 2 | {% include 'osmcal/date-base.txt' %} 3 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/date.txt: -------------------------------------------------------------------------------- 1 | {% with "jS F" as day_fmt %} 2 | {% include 'osmcal/date-base.txt' %} 3 | {% endwith %} 4 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/documentation.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} 4 | OpenStreetMap Calendar Manual 5 | {% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 | 10 |

OpenStreetMap Calendar Documentation

11 | 12 |

Why?

13 |

The OpenStreetMap Calendar (OSMCAL) has been created because we want to reach more people with the events the community creates. Organizers should have it easier to publish their events and participants should be able to find nearby events. The calendar shall be powerful, yet usable on mobile devices.

14 |

You can help us by spreading the word: Link to the calendar, create events and participate in them.

15 | 16 |

RSS

17 |

There are RSS Feeds: One global for all upcoming events, and one for each country. The base URL is events.rss. If only events in a certain country shall be returned, filters can be appended, e.g. events.rss?in=Germany.

18 | 19 |

For further information, refer to the subscription information page.

20 | 21 |

API

22 | 23 |

The available API endpoints are documented along with examples.

24 | 25 |

Changelog

26 | 27 |
    28 |
  • v2 (2020-09-30): Dates are now ISO 8601 strings with time zone info. v1 is therefore deprecated from now on.
  • 29 |
  • v2.1 (2020-10-07): A short date representation has been added to the date object (human_short) for displaying in event lists.
  • 30 |
  • v2.2 (2020-12-02): Filtering event lists by ISO country code (?in=) is now allowed and the preferred way. Filtering by country name is deprecated.
  • 31 |
32 | 33 |

Integrations

34 | 35 |

If you're looking to integrate OSMCAL into your website, consider using jbelien's openstreetmap-calendar-widget.

36 | 37 |

There is also a plugin for MediaWiki, which allows you to display events on any wiki page: OSMCAL Wiki Widget

38 | 39 |

Source Code and Issue Tracker

40 | 41 |

The OpenStreetMap Calendar is open source software and the code is hosted on GitHub. If you experience problems, you can raise an issue there.

42 | 43 |

Frequently Asked Questions

44 | 45 |

Why is there no support for repeated/periodic events?

46 | 47 |

Because we don't want to list events that potentially do not exist anymore because their initial creators forgot to remove them. Our alternative is the 'Repeat Event' functionality which you can reach from the event's detail page and allows you to copy an existing event.

48 |
49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/event.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load leaflet_tags markdown schema %} 4 | 5 | {% block title %}{{ event.name }} | OpenStreetMap Calendar{% endblock %} 6 | 7 | {% block head %} 8 | {{ block.super }} 9 | {% if not event.hidden %} 10 | 11 | 12 | {% schema_block event %} 13 | {% else %} 14 | 15 | {% endif %} 16 | 17 | {% if event.location %} 18 | {% leaflet_js %} 19 | {% leaflet_css %} 20 | 30 | {% endif %} 31 | {% endblock %} 32 | 33 | {% block content %} 34 |
35 |
36 |
37 |

{{ event.name }}

38 |

39 | {% if event.location_text %}{{ event.location_text }}
{% endif %} 40 | {% if event.location_name %}{{ event.location_name }}{% endif %} 41 |

42 | 43 |
44 | {% include "osmcal/date.txt" %} 45 |
46 | 47 |

{% if event.cancelled %}This event has been cancelled.{% endif %}

48 |

{{ event.description|markdown|safe }}

49 |

{% if event.link %} {% endif %}

50 | 51 |
52 | 108 |
109 | 110 |
111 | Created by {% for l in authors %}{{ l.created_by.name }}{% if not forloop.last %}, {% endif %}{% endfor %} 112 |
113 |
114 | {% endblock %} 115 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/event_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load leaflet_tags static tz %} 4 | 5 | {% block head %} 6 | {{ block.super }} 7 | {% include "osmcal/partials/no_index.html" %} 8 | 9 | {% leaflet_js plugins="forms" %} 10 | {% leaflet_css plugins="forms" %} 11 | 12 | 13 | 14 | 15 | 16 | {% if debug %} 17 | 18 | {% else %} 19 | 20 | {% endif %} 21 | 22 | 28 | {% endblock %} 29 | 30 | {% block content %} 31 | {% if page_title %}

{{ page_title }}

{% endif %} 32 |
33 | {% csrf_token %} 34 | 35 | {% timezone tz %} 36 |
37 | {% for field in form %} 38 |
39 | 40 | {{ field }} 41 | {{ field.attrs.items }} 42 | {% if field.help_text %}{% endif %} 43 | {% if field.errors %}{% endif %} 44 |
45 | {% endfor %} 46 |
47 | {% endtimezone %} 48 | 49 |
50 | 53 | 54 |
55 |
56 | 57 | 58 |
59 | 60 |
61 |
62 |

Question [[ index+1 ]]

63 | 64 |
65 | 66 |
Existing Questions cannot be changed.
67 | 68 |
69 | 70 | 71 |
72 | 73 |
74 | 75 | 76 |
77 | 78 |
79 | 80 | 85 |
86 | 87 |
88 | 89 |
90 | 91 | 92 |
93 |
94 | 95 | 100 |
101 | 102 | 103 | 104 | 105 | 106 |
107 | 108 | {% if event %} 109 |
110 |
111 |
112 | {% if not event.cancelled %} 113 | 114 | 115 | {% else %} 116 | 117 | {% endif %} 118 |
119 |
120 | {% endif %} 121 | 122 |
123 |

124 |
125 |
126 | 127 | 216 | {% endblock %} 217 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/event_join.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 |

Join {{ event.name }}

7 | On {% include "osmcal/date.txt" %} 8 | 9 |
10 | {% csrf_token %} 11 | 12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/event_participants.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} 4 | {{ event.name }} Participants 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 |

Participants of {{ event.name }}

11 | On {% include "osmcal/date.txt" %} 12 | 13 | {% regroup answers by user_name as participants %} 14 |
15 | 16 | 17 | 18 | 19 | {% for question in event.questions.all %} 20 | 21 | {% endfor %} 22 | 23 | {% for user, answers in participants %} 24 | 25 | 26 | 27 | {% for line in answers %} 28 | 29 | {% endfor %} 30 | 31 | {% endfor %} 32 | {% if not answers %}{% for ptc in event.participation.all %} 33 | 34 | 35 | 36 | 37 | {% endfor %}{% endif %} 38 |
User NameSigned Up On{{ question.question_text }}
{{ user }}{{ answers.0.added_on|default_if_none:""|date:"Y-m-d H:i" }}{{ line.answer|default_if_none:"" }}
{{ ptc.user.name }}{{ ptc.added_on|default_if_none:""|date:"Y-m-d H:i" }}
39 |
40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/event_survey.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load static %} 4 | 5 | {% block title %} 6 | {{ event.name }} Questionnaire 7 | {% endblock %} 8 | 9 | {% block head %} 10 | {{ block.super }} 11 | 12 | {% endblock %} 13 | 14 | {% block content %} 15 |
16 |

Attend ”{{ event.name }}”

17 |
The event organiser wants you to answer some questions for sign-up. Answers are publicly visible.
18 | 19 |
20 | 21 | {% csrf_token %} 22 | 23 |
24 | {% for field in form %} 25 |
26 | 27 | {{ field }} 28 | {% if field.help_text %}{% endif %} 29 |
30 | {% endfor %} 31 | 32 | 33 |
34 |
35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/events_past.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Past Events

6 | {% include "osmcal/partials/event_list.html" %} 7 | 8 |
9 | {% if page != 1 %} 10 | 11 | {% endif %} 12 | {% if has_more %} 13 | 14 | {% endif %} 15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/feeds/event_feed.html: -------------------------------------------------------------------------------- 1 | {% load markdown %}{% spaceless %}

{{ obj.title }}

2 |

{% include "osmcal/date.txt" with event=obj %}

3 |

{% if obj.cancelled %}This event has been cancelled.{% endif %}

4 | {% if obj.location_text %}

{{ obj.location_text }}

{% endif %} 5 |
{{ obj.description|markdown|safe }}{% if obj.link %}

Event Website

{% endif %}
{% endspaceless %} 6 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/homepage.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block head %} 5 | 6 | {% if filter.in %} 7 | 8 | {% endif %} 9 | 10 | 25 | {% endblock %} 26 | 27 | {% block title %}{% if filter.in %}OpenStreetMap Events in {{ filter.in }}{% else %}{{ block.super }}{% endif %}{% endblock %} 28 | 29 | {% block content %} 30 |
31 |
32 | 39 |
40 | 41 |
42 | 43 |
44 |
45 | 46 | {% include "osmcal/partials/event_list.html" %} 47 | 48 | OSMCAL on Mastodon 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block head %} 4 | {{ block.super }} 5 | {% include "osmcal/partials/no_index.html" %} 6 | {% endblock %} 7 | 8 | {% block title %} 9 | Login to OpenStreetMap Calendar 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 | {% if user.is_autenticated %} 15 |
You are logged in as {{ user.name }}.
16 | {% elif next %} 17 |
You need to be logged in to do that.
18 | {% endif %} 19 | 20 | {% if not user.is_authenticated %} 21 | 24 | {% endif %} 25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/partials/event_form_timezone.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 14 | 15 | 16 | 52 | 53 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/partials/event_list.html: -------------------------------------------------------------------------------- 1 | {% load tz locadate %} 2 | 3 | {% regroup events by year_month as ebd %} 4 | 5 | 25 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/partials/leaflet_widget.html: -------------------------------------------------------------------------------- 1 | {# Adapted from leaflet/widget.html #} 2 | {% load leaflet_tags l10n %} 3 | {% load static %} 4 | 5 | 46 | 47 | {% if not target_map %} 48 | {% block map %} 49 | {% leaflet_map id_map callback=id_map_callback loadevent=loadevent settings_overrides=settings_overrides %} 50 | {% endblock map %} 51 | {% endif %} 52 | 53 | 54 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/partials/no_index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/subscription_info.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block head %} 5 | 6 | {% endblock %} 7 | 8 | {% block title %}Subscribe to Events – {{ block.super }}{% endblock %} 9 | 10 | {% block content %} 11 | 12 |

Subscribe to Events{% if request.GET.in %} in {{ request.GET.in }}{% endif %}

13 | 14 |

You can get notified about new events using one of the subscription mechanisms:

15 | 16 |
    17 |
  • 18 |

    iCalendar Feed: If your calendar supports calendar subscriptions, you can add a calendar feed. Thunderbird Lightning, Apple Calendar, Fastmail and Google Calendar are known to be working.

    19 |
      20 | {% if request.GET.in %}
    • 21 | {{ request.GET.in }} subscription
      22 | https://{{ request.get_host }}{% url 'event-feed-ical' %}?in={{ request.GET.in|urlencode }}
    • {% endif %} 23 |
    • 24 | Global subscription
      25 | https://{{ request.get_host }}{% url 'event-feed-ical' %} 26 |
    • 27 |
    28 |
  • 29 |
  • 30 |

    RSS: For users of feed readers, Atom/RSS 2.0 feeds are available.

    31 |
      32 | {% if request.GET.in %}
    • 33 | {{ request.GET.in }} feed
      34 | https://{{ request.get_host }}{% url 'event-rss' %}?in={{ request.GET.in|urlencode }} 35 |
    • {% endif %} 36 |
    • 37 | Global feed
      38 | https://{{ request.get_host }}{% url 'event-rss' %} 39 |
    • 40 |
    41 |
  • 42 |
43 | 44 |

If you experience compatibility problems, feel free to report them.

45 | 46 |
47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /osmcal/templates/osmcal/user_self.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} 4 | Your Account 5 | {% endblock %} 6 | 7 | {% block content %} 8 | 9 |

Hi, {{ request.user.name }}!

10 | 11 |

User Info

12 |

You're registered since {{ request.user.date_joined|date:"Y-m-d" }}.

13 | 14 | {% if request.user.home_location %} 15 |

Your home location is {{ request.user.home_location.y }}/{{ request.user.home_location.x }}, time zone {{ request.user.home_timezone }}.

16 | {% else %} 17 |

Your home location is unknown, but you can set it in your OpenStreetMap.org user settings.

18 | {% endif %} 19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /osmcal/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thomersch/openstreetmap-calendar/7d30a576e6e4074eec59ebd8ea3b9b7f33897b1d/osmcal/templatetags/__init__.py -------------------------------------------------------------------------------- /osmcal/templatetags/locadate.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, tzinfo 2 | 3 | from babel.core import Locale 4 | from babel.dates import format_interval, format_skeleton, get_timezone, get_timezone_name 5 | from django import template 6 | from django.utils import timezone 7 | from django.utils.translation import gettext 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.simple_tag() 13 | def loca_day_month_fmt(lang): 14 | fmt_str = gettext("day_month_format") 15 | if fmt_str == "PLACEHOLDER": 16 | """We're hacking around the fallback language here: 17 | jS F would be 1st May in en, but in everything else we'd have 1st Май, which doesn't make any sense, so we have a placeholder in the translation and going to fallback to j E which is reasonable in most languages. 18 | """ 19 | if lang.startswith("en"): 20 | return "jS F" 21 | else: 22 | return "j E" 23 | return fmt_str 24 | 25 | 26 | @register.simple_tag() 27 | def loca_day_fmt(lang): 28 | fmt_str = gettext("day_only_format") 29 | if fmt_str == "PLACEHOLDER": 30 | if lang.startswith("en"): 31 | return "jS" 32 | else: 33 | return "j" 34 | return fmt_str 35 | 36 | 37 | DEFAULT_LOCALE = "en_GB" 38 | 39 | 40 | def with_timezone(original, evt, locale, user_tz: tzinfo = None): 41 | """ 42 | The timezone concatenation might be incorrect in some cases, but there 43 | seems not to be a format string for this purpose. 44 | """ 45 | dt = evt.start_localized 46 | if user_tz: 47 | dt = evt.start.astimezone(user_tz) 48 | 49 | """ 50 | This is just a hack to prevent an ugly display like "Unknown Region 51 | (GMT) Time". It should be replaced with something better. 52 | """ 53 | if dt.tzname() == "GMT": 54 | return original + " GMT" 55 | elif dt.tzname() == "UTC": 56 | return original + " UTC" 57 | 58 | return original + " " + get_timezone_name(dt.tzinfo, width="short", locale=locale) 59 | 60 | 61 | @register.simple_tag() 62 | def full_date_format(evt, locale=DEFAULT_LOCALE, user_tz: tzinfo = None) -> str: 63 | """ 64 | full_date_format formats the date/time of an event. 65 | There are several things to keep in mind: 66 | - Events always have a start, but not always a specified end. 67 | - They might be just a range of dates (whole_day). 68 | - Events are located in some timezone. 69 | - If the consumer is in a different timezone than the event, it should 70 | be converted. 71 | - If the event is a whole day event, timezone should not be 72 | converted as this would bring dates out of alignment. 73 | - They should be displayed in a localized, but concise fashion, without 74 | unnecessary repetition. 75 | """ 76 | lo = Locale.parse(locale, sep="_") 77 | tz = get_timezone(evt.timezone) 78 | 79 | if evt.end: 80 | if evt.whole_day: 81 | return format_interval(evt.start.date(), evt.end.date(), "yMMMMd", tzinfo=tz, locale=lo) 82 | else: 83 | if evt.start.date() == evt.end.date(): 84 | fmt = lo.datetime_formats["medium"].replace("'", "") 85 | return with_timezone( 86 | fmt.format( 87 | format_interval(evt.start.time(), evt.end.time(), "Hm", tzinfo=tz, locale=lo), 88 | format_skeleton("yMMMMd", evt.start, tzinfo=tz, locale=lo), 89 | ), 90 | evt, 91 | lo, 92 | user_tz, 93 | ) 94 | return with_timezone( 95 | format_interval(evt.start, evt.end, skeleton="yMMMMd Hm", tzinfo=tz, locale=lo), evt, lo, user_tz 96 | ) 97 | 98 | # Event has no end date 99 | if evt.whole_day: 100 | return format_skeleton("yMMMMd", evt.start, tzinfo=tz, locale=lo) 101 | else: 102 | fmt = lo.datetime_formats["medium"].replace("'", "") 103 | return with_timezone( 104 | fmt.format( 105 | format_skeleton("Hm", evt.start, tzinfo=tz, locale=lo), 106 | format_skeleton("yMMMMd", evt.start, tzinfo=tz, locale=lo), 107 | ), 108 | evt, 109 | lo, 110 | user_tz, 111 | ) 112 | 113 | 114 | @register.simple_tag() 115 | def short_date_format(evt, locale=DEFAULT_LOCALE, user_tz: tzinfo = None) -> str: 116 | lo = Locale.parse(locale, sep="_") 117 | tz = get_timezone(evt.timezone) 118 | now = timezone.now() 119 | 120 | if evt.end: 121 | if evt.start.year != now.year or evt.end.year != evt.start.year: 122 | return format_interval(evt.start.date(), evt.end.date(), "yMMMMd", tzinfo=tz, locale=lo) 123 | else: 124 | # Year can be skipped 125 | return format_interval(evt.start.date(), evt.end.date(), "MMMd", tzinfo=tz, locale=lo) 126 | 127 | if evt.start.year != now.year: 128 | return format_skeleton("yMMMMd", evt.start, tzinfo=tz, locale=lo) 129 | else: 130 | # Year can be skipped 131 | return format_skeleton("MMMMd", evt.start, tzinfo=tz, locale=lo) 132 | 133 | 134 | @register.filter() 135 | def year_month_to_title(ym): 136 | now = timezone.now() 137 | 138 | if ym[0] == now.year and ym[1] == now.month: 139 | return "" 140 | 141 | ymo = datetime(month=ym[1], year=ym[0], day=1) 142 | if ym[0] == now.year: 143 | return datetime.strftime(ymo, "%B") 144 | else: 145 | return datetime.strftime(ymo, "%B %Y") 146 | -------------------------------------------------------------------------------- /osmcal/templatetags/markdown.py: -------------------------------------------------------------------------------- 1 | import bleach 2 | import markdown as md 3 | from bleach.linkifier import LinkifyFilter 4 | from django import template 5 | 6 | allowed_tags = [ 7 | "a", 8 | "abbr", 9 | "acronym", 10 | "b", 11 | "blockquote", 12 | "br", 13 | "code", 14 | "em", 15 | "h1", 16 | "h2", 17 | "h3", 18 | "h4", 19 | "h5", 20 | "h6", 21 | "hr", 22 | "i", 23 | "li", 24 | "ol", 25 | "p", 26 | "pre", 27 | "strong", 28 | "ul", 29 | ] 30 | 31 | register = template.Library() 32 | cleaner = bleach.Cleaner(tags=allowed_tags, filters=[LinkifyFilter]) 33 | 34 | 35 | @register.filter(is_safe=True) 36 | def markdown(value): 37 | if not value: 38 | return "" 39 | return cleaner.clean(md.markdown(value)) 40 | 41 | 42 | @register.tag() 43 | def markdownify(parser, token): 44 | nodelist = parser.parse(("endmarkdownify",)) 45 | parser.delete_first_token() 46 | return Markdownify(nodelist) 47 | 48 | 49 | class Markdownify(template.Node): 50 | def __init__(self, nodelist): 51 | self.nodelist = nodelist 52 | 53 | def render(self, context): 54 | output = self.nodelist.render(context) 55 | return cleaner.clean(markdown(output)) 56 | -------------------------------------------------------------------------------- /osmcal/templatetags/schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django import template 4 | from django.utils.safestring import mark_safe 5 | from osmcal.models import Event 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.simple_tag() 11 | def schema_block(evt): 12 | if not isinstance(evt, Event): 13 | raise TypeError("parameter needs to be an event") 14 | data = { 15 | "@context": "https://schema.org", 16 | "@type": "Event", 17 | "startDate": evt.start_localized.isoformat(), 18 | "name": evt.name, 19 | } 20 | if evt.end: 21 | data["endDate"] = evt.end_localized.isoformat() 22 | if evt.description: 23 | data["description"] = evt.description 24 | 25 | if evt.cancelled: 26 | data["eventStatus"] = "https://schema.org/EventCancelled" 27 | else: 28 | data["eventStatus"] = "https://schema.org/EventScheduled" 29 | 30 | addr = None 31 | if evt.location_address: 32 | addr = { 33 | "@type": "PostalAddress", 34 | "addressLocality": ", ".join( 35 | filter( 36 | lambda x: x is not None, 37 | [ 38 | evt.location_address.get("village"), 39 | evt.location_address.get("town"), 40 | evt.location_address.get("city"), 41 | ], 42 | ) 43 | ), 44 | } 45 | if "country_code" in evt.location_address: 46 | addr["addressCountry"] = evt.location_address.get("country_code").upper() 47 | if "state" in evt.location_address: 48 | addr["addressRegion"] = evt.location_address.get("state") 49 | if "postcode" in evt.location_address: 50 | addr["postalCode"] = evt.location_address.get("postcode") 51 | if "road" in evt.location_address: 52 | addr["streetAddress"] = ", ".join( 53 | filter( 54 | lambda x: x is not None, 55 | [evt.location_address.get("house_number"), evt.location_address.get("road")], 56 | ) 57 | ) 58 | 59 | if addr or evt.location or evt.location_name: 60 | data["location"] = { 61 | "@type": "Place", 62 | } 63 | if evt.location: 64 | data["location"]["latitude"] = evt.location.y 65 | data["location"]["longitude"] = evt.location.x 66 | if addr: 67 | data["location"]["address"] = addr 68 | if evt.location_name: 69 | data["location"]["name"] = evt.location_name 70 | 71 | if evt.link: 72 | data["url"] = evt.link 73 | 74 | return mark_safe("""""".format(json.dumps(data))) 75 | -------------------------------------------------------------------------------- /osmcal/templatetags/tabless.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.tag() 7 | def tabless(parser, token): 8 | nodelist = parser.parse(("endtabless",)) 9 | parser.delete_first_token() 10 | return Tabless(nodelist) 11 | 12 | 13 | class Tabless(template.Node): 14 | def __init__(self, nodelist): 15 | self.nodelist = nodelist 16 | 17 | def render(self, context): 18 | output = self.nodelist.render(context) 19 | return output.replace("\t", "").replace("\n", "") 20 | -------------------------------------------------------------------------------- /osmcal/test_dateformat.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.template.loader import render_to_string 4 | from django.test import SimpleTestCase 5 | from django.utils import translation 6 | 7 | # from .models import Event 8 | 9 | 10 | class MockEvent(object): 11 | def __init__(self, start, end=None, whole_day=False, location="Dummy"): 12 | self.start = start 13 | self.end = end 14 | self.whole_day = whole_day 15 | self.timezone = "UTC" 16 | self.location = location 17 | 18 | 19 | class DateFormatTest(SimpleTestCase): 20 | def _fmt(self, evt): 21 | return render_to_string("osmcal/date.txt", {"event": evt}).strip() 22 | 23 | def _fmt_loca(self, evt): 24 | return render_to_string("osmcal/date.l10n.txt", {"event": evt}).strip() 25 | 26 | def setUp(self): 27 | self.cur = datetime.now() 28 | 29 | def test_dateformat_start(self): 30 | self.assertEqual( 31 | self._fmt(MockEvent(start=datetime(year=self.cur.year, month=1, day=1), whole_day=True)), "1st January" 32 | ) 33 | 34 | def test_dateformat_start_with_time(self): 35 | self.assertEqual( 36 | self._fmt(MockEvent(start=datetime(year=self.cur.year, month=1, day=1, hour=18))), "1st January 18:00" 37 | ) 38 | 39 | def test_dateformat_start_end_with_time(self): 40 | self.assertEqual( 41 | self._fmt( 42 | MockEvent( 43 | start=datetime(year=self.cur.year, month=1, day=2, hour=16), 44 | end=datetime(year=self.cur.year, month=1, day=2, hour=18), 45 | ) 46 | ), 47 | "2nd January 16:00 – 18:00", 48 | ) 49 | 50 | def test_dateformat_start_end_with_time_multiday(self): 51 | self.assertEqual( 52 | self._fmt( 53 | MockEvent( 54 | start=datetime(year=self.cur.year, month=1, day=2, hour=16), 55 | end=datetime(year=self.cur.year, month=1, day=3, hour=18), 56 | ) 57 | ), 58 | "2nd January 16:00 – 3rd January 18:00", 59 | ) 60 | 61 | # def test_dateformat_start_end(self): 62 | # self.assertEqual( 63 | # self._fmt(MockEvent(start=datetime(year=self.cur.year, month=1, day=1), end=datetime(year=self.cur.year, month=1, day=2), whole_day=True)), 64 | # '1st-2nd January' 65 | # ) 66 | 67 | def test_dateformat_next_year(self): 68 | self.assertEqual( 69 | self._fmt(MockEvent(start=datetime(year=self.cur.year + 1, month=1, day=2), whole_day=True)), 70 | "2nd January " + str(self.cur.year + 1), 71 | ) 72 | 73 | def test_dateformat_past_year(self): 74 | self.assertEqual( 75 | self._fmt(MockEvent(start=datetime(year=self.cur.year - 1, month=1, day=2), whole_day=True)), 76 | "2nd January " + str(self.cur.year - 1), 77 | ) 78 | 79 | def test_dateformat_past_year_with_time_interval(self): 80 | self.assertEqual( 81 | self._fmt( 82 | MockEvent( 83 | start=datetime(year=self.cur.year - 1, month=1, day=2, hour=10), 84 | end=datetime(year=self.cur.year - 1, month=1, day=2, hour=12), 85 | whole_day=False, 86 | ) 87 | ), 88 | "2nd January " + str(self.cur.year - 1) + " 10:00 – 12:00", 89 | ) 90 | 91 | def test_localized_de(self): 92 | translation.activate("de-DE") 93 | self.assertEqual( 94 | self._fmt_loca(MockEvent(start=datetime(year=self.cur.year, month=10, day=3), whole_day=True)), "3. Oktober" 95 | ) 96 | translation.deactivate() 97 | 98 | def test_localized_fr(self): 99 | translation.activate("fr-FR") 100 | self.assertEqual( 101 | self._fmt_loca(MockEvent(start=datetime(year=self.cur.year, month=10, day=3), whole_day=True)), "3 Octobre" 102 | ) 103 | translation.deactivate() 104 | -------------------------------------------------------------------------------- /osmcal/test_feeds.py: -------------------------------------------------------------------------------- 1 | from django.test import Client, TestCase 2 | 3 | 4 | class FeedBaseTest(TestCase): 5 | def test_rss_base(self): 6 | c = Client() 7 | resp = c.get("/events.rss") 8 | 9 | self.assertEqual(resp.status_code, 200) 10 | -------------------------------------------------------------------------------- /osmcal/test_ical.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.test import TestCase 4 | 5 | from .ical import encode_event, encode_events 6 | from .models import Event 7 | 8 | 9 | class IcalEncodeTest(TestCase): 10 | def test_single_event(self): 11 | evt = Event.objects.create( 12 | id=12, 13 | start=datetime(year=2032, month=1, day=3, hour=12, minute=00), 14 | end=datetime(year=2032, month=1, day=3, hour=14, minute=00), 15 | timezone="UTC", 16 | ) 17 | self.assertIsInstance(encode_event(evt), str) 18 | 19 | def test_multiple_events(self): 20 | Event.objects.create( 21 | name="First EVT", 22 | start=datetime(year=2021, month=1, day=3, hour=12, minute=00), 23 | end=datetime(year=2021, month=1, day=3, hour=14, minute=00), 24 | timezone="Europe/Berlin", 25 | ) 26 | Event.objects.create( 27 | name="Some Event", 28 | start=datetime(year=2021, month=1, day=4, hour=12, minute=00), 29 | end=datetime(year=2021, month=1, day=4, hour=14, minute=00), 30 | timezone="Europe/Berlin", 31 | ) 32 | 33 | ics = encode_events(Event.objects.all()) 34 | self.assertIsInstance(ics, str) 35 | -------------------------------------------------------------------------------- /osmcal/test_views.py: -------------------------------------------------------------------------------- 1 | from django.test import Client, TestCase 2 | 3 | 4 | class EventListTest(TestCase): 5 | def test_location_out_of_range(self): 6 | # Based on Sentry report OSM-CALENDAR-1W 7 | c = Client() 8 | resp = c.get("/events.ics?around=5564") # The around parameter obviously doesn't make any sense. 9 | self.assertEqual(resp.status_code, 400) 10 | 11 | def test_location_radius_out_of_range(self): 12 | c = Client() 13 | resp = c.get("/events.ics?around=52,13&around_radius=260") # The around radius parameter is too large. 14 | self.assertEqual(resp.status_code, 400) 15 | 16 | def test_location_around_50k(self): 17 | c = Client() 18 | resp = c.get("/events.ics?around=52,13") 19 | self.assertEqual(resp.status_code, 200) 20 | 21 | def test_location_bad_syntax(self): 22 | c = Client() 23 | resp = c.get("/events.ics?around=52\\,13") 24 | self.assertEqual(resp.status_code, 400) 25 | 26 | def test_location_around_dist(self): 27 | c = Client() 28 | resp = c.get("/events.ics?around=52,13&around_radius=5") 29 | self.assertEqual(resp.status_code, 200) 30 | -------------------------------------------------------------------------------- /osmcal/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import include, path 3 | 4 | from . import views 5 | 6 | app_name = "osmcal" 7 | 8 | urlpatterns = [ 9 | path("", views.Homepage.as_view(), name="homepage"), 10 | path("subscribe/", views.SubscriptionInfo.as_view(), name="subscription-info"), 11 | path("event/add/", views.EditEvent.as_view(), name="event-edit"), 12 | path("event//", views.EventView.as_view(), name="event"), 13 | path("event/.ics", views.EventICal.as_view(), name="event-ical"), 14 | path("event//cancel/", views.CancelEvent.as_view(), name="event-cancel"), 15 | path( 16 | "event//uncancel/", 17 | views.UncancelEvent.as_view(), 18 | name="event-uncancel", 19 | ), 20 | path("event//change/", views.EditEvent.as_view(), name="event-change"), 21 | path( 22 | "event//duplicate/", 23 | views.DuplicateEvent.as_view(), 24 | name="event-duplicate", 25 | ), 26 | path("event//hide/", views.HideEvent.as_view(), name="event-hide"), 27 | path("event//unhide/", views.UnhideEvent.as_view(), name="event-unhide"), 28 | path("event//join/", views.JoinEvent.as_view(), name="event-join"), 29 | path("event//unjoin/", views.UnjoinEvent.as_view(), name="event-unjoin"), 30 | path( 31 | "event//participants/", 32 | views.EventParticipants.as_view(), 33 | name="event-participants", 34 | ), 35 | path("events/past/", views.PastEvents.as_view(), name="events-past"), 36 | path("events/past//", views.PastEvents.as_view(), name="events-past"), 37 | path("events.rss", views.EventFeed(), name="event-rss"), 38 | path("events.ics", views.EventFeedICal.as_view(), name="event-feed-ical"), 39 | path("login/", views.login, name="login"), 40 | path("logout/", views.logout, name="logout"), 41 | path("oauth/start/", views.oauth_start, name="oauth-start"), 42 | path("oauth/callback/", views.oauth_callback, name="oauth-callback"), 43 | path("documentation/", views.Documentation.as_view(), name="api-manual"), 44 | path("me/", views.CurrentUserView.as_view(), name="user-self"), 45 | path("", include("django_prometheus.urls")), 46 | path("api/", include("osmcal.api.urls")), 47 | ] 48 | 49 | if settings.DEBUG: 50 | urlpatterns.append(path("login/mock/", views.MockLogin.as_view(), name="login-mock")) 51 | -------------------------------------------------------------------------------- /osmcal/widgets.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | from django.forms.widgets import Widget 3 | from leaflet.forms.widgets import LeafletWidget as StockLeafletWidget 4 | 5 | 6 | class TimezoneWidget(Widget): 7 | template_name = "osmcal/partials/event_form_timezone.html" 8 | 9 | def get_context(self, *args, **kwargs): 10 | ctx = super().get_context(*args, **kwargs) 11 | ctx["all_timezones"] = pytz.common_timezones 12 | return ctx 13 | 14 | 15 | class LeafletWidget(StockLeafletWidget): 16 | template_name = "osmcal/partials/leaflet_widget.html" 17 | -------------------------------------------------------------------------------- /osmcal/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for osmcal project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "osmcal.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /postgres/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1745234285, 24 | "narHash": "sha256-GfpyMzxwkfgRVN0cTGQSkTC0OHhEkv3Jf6Tcjm//qZ0=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "c11863f1e964833214b767f4a369c6e6a7aba141", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /postgres/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A flake that adds the postgis extension to Postgresql"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let pkgs = nixpkgs.legacyPackages.${system}; 12 | in { 13 | packages = { 14 | # Return postgrsql with the postgis extension include. 15 | postgresql = pkgs.postgresql_15.withPackages (p: [ p.postgis ]); 16 | }; 17 | 18 | defaultPackage = self.packages.postgresql; 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /process-compose.yml: -------------------------------------------------------------------------------- 1 | processes: 2 | osmcal: 3 | command: make devserver 4 | availability: 5 | restart: always 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ['py39'] 4 | extend-exclude = 'osmcal/migrations' 5 | 6 | [tool.pyright] 7 | venvPath = "." 8 | venv = ".venv" 9 | 10 | [project] 11 | authors = [{ name = "Thomas Skowron", email = "th@skowron.eu" }] 12 | license = { text = "APACHE 2.0" } 13 | requires-python = "<4.0,>=3.11" 14 | dependencies = [ 15 | "psycopg2-binary~=2.8", 16 | "requests~=2.32", 17 | "requests-oauthlib~=1.2", 18 | "django-leaflet~=0.27", 19 | "Django~=5.2", 20 | "Markdown~=3.2", 21 | "bleach~=6.2.0", 22 | "gunicorn~=22.0", 23 | "python-dotenv~=1.1.0", 24 | "sentry-sdk~=2.8", 25 | "django-prometheus~=2.0", 26 | "timezonefinder~=6.0", 27 | "Babel~=2.9", 28 | "django4-background-tasks~=1.2.10", 29 | "pytz~=2025.2", 30 | ] 31 | name = "openstreetmap-calendar" 32 | version = "2.2.0" 33 | description = "" 34 | package-mode = false 35 | 36 | [dependency-groups] 37 | dev = ["PyYaml~=6.0", "pylint", "black~=24.3.0"] 38 | --------------------------------------------------------------------------------