Release v1.0.0
Some checks failed
CI / Checks (push) Failing after 13m2s

This commit is contained in:
Tomek Wójcik 2025-08-20 21:00:50 +02:00
commit b4338e2769
401 changed files with 23576 additions and 0 deletions

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
services/keycloak/fixtures/*.json filter=git-crypt diff=git-crypt
services/tls/*.crt filter=git-crypt diff=git-crypt
services/tls/*.key filter=git-crypt diff=git-crypt

70
.gitea/workflows/ci.yaml Normal file
View File

@ -0,0 +1,70 @@
name: "CI"
on:
push:
pull_request:
jobs:
run-checks:
name: "Checks"
runs-on: "ubuntu-latest"
steps:
- name: "Checkout the code"
uses: "actions/checkout@v2"
- name: "Set up Docker Buildx"
uses: "docker/setup-buildx-action@v3"
- name: "Build `postgres` image"
uses: docker/build-push-action@v6
with:
file: "services/postgres/Dockerfile"
context: "services/"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-local"
- name: "Build `keycloak` image"
uses: docker/build-push-action@v6
with:
file: "services/keycloak/Dockerfile"
context: "services/"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-local"
- name: "Build `rabbitmq` image"
uses: docker/build-push-action@v6
with:
file: "services/rabbitmq/Dockerfile"
context: "services/"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-local"
- name: "Build `backend-ci` image"
uses: docker/build-push-action@v6
with:
file: "services/backend/Dockerfile"
context: "services/"
target: "ci"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local"
- name: "Build `packages-ci` image"
uses: docker/build-push-action@v6
with:
file: "services/packages/Dockerfile"
context: "services/"
target: "ci"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-local"
- name: "Run `backend` checks"
run: |
set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml run --rm backend-ci inv ci
- name: "Run `packages` checks"
run: |
set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml run --rm packages-ci inv ci
- name: "Clean up"
if: always()
run: |
set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml down --volumes

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.envrc*
.ipythonhome/

202
LICENSE Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

88
NOTICE.txt Normal file
View File

@ -0,0 +1,88 @@
HotPocket by BTHLabs
Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---
HotPocket by BTHLabs includes the following third party software
Bootstrap
Copyright 2011-2025 The Bootstrap Authors
Licensed under terms of the MIT License
Bootstrap Icons
Copyright 2019-2024 The Bootstrap Authors
Licensed under terms of the MIT License
htmx.org
Licensed under terms of the Zero-Clause BSD License
celery
Copyright (c) 2017-2026 Asif Saif Uddin, core team & contributors. All rights reserved.
Copyright (c) 2015-2016 Ask Solem & contributors. All rights reserved.
Copyright (c) 2012-2014 GoPivotal, Inc. All rights reserved.
Copyright (c) 2009, 2010, 2011, 2012 Ask Solem, and individual contributors. All rights reserved.
Licensed under terms of The BSD License (3 Clause, also known as the new BSD license)
crispy-bootstrap5
Copyright (c) 2020 David Smith and contributors.
Licensed under terms of the MIT License
django
Copyright (c) Django Software Foundation and individual contributors.
Licensed under terms of The BSD License (3 Clause, also known as the new BSD license)
django-crispy-form
Copyright (c) 2009-2021 Miguel Araujo, Daniel Feldroy and contributors.
Licensed under terms of the MIT License
django-hmtx
Copyright (c) Adam Johnson
Licensed under terms of the MIT License
psycopg
Copyright (c) Daniele Varrazzo
Licensed under terms of the GNU Lesser General Public License v3.0
pydantic
Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors.
Licensed under terms of the MIT License
pyquery
Copyright (C) 2008 - Olivier Lauzanne <olauzanne@gmail.com>
Licensed under terms of The BSD License (3 Clause, also known as the new BSD license)
requests
Copyright 2019 Kenneth Reitz
Licensed under terms of the Apache License 2.0
social-auth-app-django
Copyright (c) 2012-2016, Matías Aguirre
Licensed under terms of The BSD License (3 Clause, also known as the new BSD license)
social-auth-core
Copyright (c) 2012-2016, Matías Aguirre
Licensed under terms of The BSD License (3 Clause, also known as the new BSD license)
sqlalchemy
Copyright 2005-2025 SQLAlchemy authors and contributors
Licensed under terms of the MIT License
uuid6
Copyright (c) 2021 oittaa
Licensed under terms of the MIT License
Pepper Hot Solid icon
Copyright (c) Icons8
Licensed under terms of the MIT License

167
README.md Normal file
View File

@ -0,0 +1,167 @@
# HotPocket by BTHLabs
This repository contains the _HotPocket_ project.
## Development setup
### Requirements:
* Python 3.12,
* Poetry 1.8.3,
* `git-crypt`,
* Docker with Docker Compose and Buildx.
### Setup
1. `$ git-crypt unlock KEYFILE`
1. `$ poetry install`
1. `$ docker buildx bake`
1. `$ poetry run inv setup`
### Running local development stack
1. `$ docker compose up`
**Exported services:**
* The app: https://app.hotpocket.work.bthlabs.net:8000/
* The admin: https://admin.hotpocket.work.bthlabs.net:8000/
* Keycloak: https://auth.hotpocket.work.bthlabs.net:8443/
* Postgres: postgres://postgres.hotpocket.work.bthlabs.net:5432/
* RabbitMQ: amqp://rabbitmq.hotpocket.work.bthlabs.net:5672/
* RabbitMQ Management: amqp://rabbitmq.hotpocket.work.bthlabs.net:15672/
**Default credentials:**
The default credentials across most of the services are:
`hotpocket:hotpocketm4st3r`. This applies to the initial app and admin account,
Keycloak master realm, Postgres and RabbitMQ.
The Keycloak `hotpocket-development` realm user's credentials are:
`hotpocket@bthlabs.net:hotpocketm4st3r`. You can use these to log in to the app
and admin using OIDC.
## Deployment
There are two deployment images - `aio` and `deployment`.
### The AIO image
The `aio` image is pre-configured for running small instances in a single
container:
* It defaults to SQLite database.
* It defaults to running all background tasks in the foreground.
* It defaults to accepting traffic with any `Host` HTTP header.
The `aio` image is recommended for self-hosting with minimal use, e.g. by a
single user.
**Example:**
```
$ docker run --rm -it \
-v `realpath run/`:/srv/run \
-e HOTPOCKET_BACKEND_SECRET_KEY=thisisntright \
-e HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME=hotpocket \
-e HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD=hotpocketm4st3r \
-p 8000:8000 \
docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v1.0.0-01
```
The command above will set up and start the application. The SQLite file will
be placed in `run/hotpocket-backend-aio.sqlite` and database migrations will
be ran. The initial superuser account will be created with the specified
credentials. The Web app will be reachable at `http://127.0.0.1:8000/`.
The admin will be reachable at `http://127.0.0.1:8000/admin/`.
The `DJANGO_SETTINGS_MODULE` environment variable defaults to
`hotpocket_backend.settings.deployment.webapp`. This should be set to
`hotpocket_backend.settings.deployment.admin` in the Admin container.
**NOTE:** The command above specifies wildly insecure `SECRET_KEY` which is
used among other things to secure the session cookie. Please *please*
**please** don't run it like this. Not even in your homelab :).
The `deployment/aio/docker-compose.yaml` file can be used as a starting
point for AIO deployments.
### The Deployment image
The `deployment` image doesn't make any assumptions about the env and in turn
will require the operator to configure database, Celery broker and result
backend etc. The final deployment will require services for at least the Web
app, the Celery worker and Celery Beat. Admin is optional.
The `DJANGO_SETTINGS_MODULE` environment variable defaults to
`hotpocket_backend.settings.deployment.aio`.
The `deployment/fullstack/docker-compose.yaml` file can be used as a
starting point for full-stack deployments.
### Configuration environment variables
HotPocket deployment images provide extensive set of environment variables
that can be used to configure the services.
| Variable | Default | Description |
|----------------------------------------------|-----------------------------------------------------------------|---------------------------------------------------------------------------------------------|
| `HOTPOCKET_BACKEND_ENV` | `deployment` or `aio` | The environment name. See below. |
| `HOTPOCKET_BACKEND_APP` | `webapp` | The app name. See below. |
| `HOTPOCKET_BACKEND_DEBUG` | `false` | Django `DEBUG` setting. **Do not enable in production**. Only effective in the AIO image. |
| `HOTPOCKET_BACKEND_ALLOWED_HOSTS` | N/A or `*` | Django `ALLOWED_HOSTS` setting. **Required in the Deployment image.** |
| `HOTPOCKET_BACKEND_SECRET_KEY` | N/A | Django `SECRET_KEY` setting. Recommended different for the Web app and Admin. **Required**. |
| `HOTPOCKET_BACKEND_DATABASE_ENGINE` | `django.db.backends.postgresql` or `django.db.backends.sqlite3` | The database configuration engine. |
| `HOTPOCKET_BACKEND_DATABASE_NAME` | N/A or `/srv/run/hotpocket-backend-aio.sqlite` | The database name. |
| `HOTPOCKET_BACKEND_DATABASE_USER` | N/A or N/A | The database user. |
| `HOTPOCKET_BACKEND_DATABASE_PASSWORD` | N/A | The database password. |
| `HOTPOCKET_BACKEND_DATABASE_HOST` | N/A | The database host. |
| `HOTPOCKET_BACKEND_DATABASE_PORT` | `5432` or N/A | The database port. |
| `HOTPOCKET_BACKEND_MODEL_AUTH_IS_DISABLED` | `false` | Set to `true` to disable username and password login. |
| `HOTPOCKET_BACKEND_OIDC_PAYLOAD` | N/A | The OIDC configuration payload. |
| `HOTPOCKET_BACKEND_CELERY_BROKER_URL` | N/A | The Celery broker URL. |
| `HOTPOCKET_BACKEND_CELERY_RESULT_BACKEND` | N/A | The Celery result backend URL. |
| `HOTPOCKET_BACKEND_CELERY_IGNORE_RESULT` | `false` | Set to `true` to prevent Celery from saving task results. |
| `HOTPOCKET_BACKEND_CELERY_ALWAYS_EAGER` | `false` | Set to `true` to run Celery tasks in the foreground. |
| `HOTPOCKET_BACKEND_UPLOADS_PATH` | `/srv/uploads` or `/srv/run/uploads` | The absolute path to user-uploaded files. |
| `HOTPOCKET_BACKEND_GUNICORN_WORKERS` | `4` or `2` | The number of Gunicorn workers to run for Web servers. |
| `HOTPOCKET_BACKEND_RUN_MIGRATIONS` | `false` or `true` | Set to `true` to run database muigrations when the container starts. |
| `HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME` | N/A | Username for the initial account. |
| `HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD` | N/A | Password for the initial account. |
**Env and App settings**
The `HOTPOCKET_BACKEND_ENV` and `HOTPOCKET_BACKEND_APP` variables are used
internally to resolve other settings and identify the running app.
`HOTPOCKET_BACKEND_ENV` should only be changed if when creating heavily
customized version of the project. `HOTPOCKET_BACKEND_APP` should generally be
set to `admin` only in the Admin container.
**OIDC login configuration**
The `HOTPOCKET_BACKEND_OIDC_PAYLOAD` can be used to enable OIDC login. It must
be a JSON string that deserializes to the following object:
```
{
"endpoint": "https://some.oidc.host/some-realm/",
"key": "client-key",
"secret": "client-secret",
"scope": ["roles"],
"display_name": "My OIDC server"
}
```
The `scope` field specified additional scopes to request from IdP. It can be
ommited and defaults to `["roles"]`. The `display_name` field specifies the
method's name in the UI and defaults to `OIDC`.
**NOTE:** Currently, only Keycloak has been tested with this login method.
## Author
_HotPocket_ is developed by [BTHLabs](https://www.bthlabs.pl/).
## License
_HotPocket_ is licensed under the Apache 2.0 License.

2
deployment/aio/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
run/*.sqlite
uploads/

View File

@ -0,0 +1,12 @@
services:
backend:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v1.0.0-01"
environment:
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME: "hotpocket"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD: "hotpocketm4st3r"
ports:
- "8000:8000"
volumes:
- "./run:/srv/run"
restart: "unless-stopped"

View File

2
deployment/fullstack/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
run/celery-beat-schedule
uploads/*.csv

View File

@ -0,0 +1,77 @@
x-backend-environment: &x-backend-environment
HOTPOCKET_BACKEND_DATABASE_NAME: "hotpocket_backend_staging"
HOTPOCKET_BACKEND_DATABASE_USER: "hotpocket"
HOTPOCKET_BACKEND_DATABASE_PASSWORD: "hotpocketm4st3r"
HOTPOCKET_BACKEND_DATABASE_HOST: "databases.bthlab.bthlabs.net"
HOTPOCKET_BACKEND_CELERY_BROKER_URL: "amqp://hotpocket:hotpocketm4st3r@databases.bthlab.bthlabs.net/hotpocket_backend_staging"
HOTPOCKET_BACKEND_CELERY_RESULT_BACKEND: "db+postgresql+psycopg://hotpocket:hotpocketm4st3r@databases.bthlab.bthlabs.net/hotpocket_backend_staging"
services:
webapp:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v1.0.0-01"
environment:
<<: *x-backend-environment
HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net"
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
ports:
- "8000:8000"
volumes:
- "./run:/srv/run"
- "./uploads:/srv/uploads"
restart: "unless-stopped"
admin:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v1.0.0-01"
environment:
<<: *x-backend-environment
HOTPOCKET_BACKEND_APP: "admin"
HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net"
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
ports:
- "8001:8000"
volumes:
- "./run:/srv/run"
- "./uploads:/srv/uploads"
restart: "unless-stopped"
celery-worker:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v1.0.0-01"
command:
- "/srv/venv/bin/celery"
- "-A"
- "hotpocket_backend.celery:app"
- "worker"
- "-l"
- "INFO"
- "-Q"
- "celery,webapp"
- "-c"
- "2"
environment:
<<: *x-backend-environment
HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net"
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
volumes:
- "./run:/srv/run"
- "./uploads:/srv/uploads"
restart: "unless-stopped"
celery-beat:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v1.0.0-01"
command:
- "/srv/venv/bin/celery"
- "-A"
- "hotpocket_backend.celery:app"
- "beat"
- "-l"
- "INFO"
- "-s"
- "/srv/run/celery-beat-schedule"
environment:
<<: *x-backend-environment
HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net"
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
volumes:
- "./run:/srv/run"
- "./uploads:/srv/uploads"
restart: "unless-stopped"

View File

111
docker-bake.json Normal file
View File

@ -0,0 +1,111 @@
{
"group": {
"default": {
"targets": [
"backend-management",
"caddy",
"keycloak",
"packages-management",
"postgres",
"rabbitmq"
]
}
},
"target": {
"backend-management": {
"context": "services/",
"dockerfile": "backend/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
],
"target": "development",
"output": [
"type=docker,load=true,push=false"
]
},
"backend-aio-webapp": {
"context": "services/",
"dockerfile": "backend/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-local"
],
"target": "aio",
"output": [
"type=docker,load=true,push=false"
]
},
"backend-ci": {
"context": "services/",
"dockerfile": "backend/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local"
],
"target": "ci",
"output": [
"type=docker,load=true,push=false"
]
},
"packages-management": {
"context": "services/",
"dockerfile": "packages/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/packages:local"
],
"target": "development",
"output": [
"type=docker,load=true,push=false"
]
},
"packages-ci": {
"context": "services/",
"dockerfile": "packages/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-local"
],
"target": "ci",
"output": [
"type=docker,load=true,push=false"
]
},
"caddy": {
"context": "services/",
"dockerfile": "caddy/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/caddy:2.9.1-local"
],
"output": [
"type=docker,load=true,push=false"
]
},
"postgres": {
"context": "services/",
"dockerfile": "postgres/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-local"
],
"output": [
"type=docker,load=true,push=false"
]
},
"keycloak": {
"context": "services/",
"dockerfile": "keycloak/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-local"
],
"output": [
"type=docker,load=true,push=false"
]
},
"rabbitmq": {
"context": "services/",
"dockerfile": "rabbitmq/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-local"
],
"output": [
"type=docker,load=true,push=false"
]
}
}
}

10
docker-compose-aio.yaml Normal file
View File

@ -0,0 +1,10 @@
services: {}
include:
- path: "./docker-compose-caddy.yaml"
- path: "./services/backend/docker-compose-aio.yaml"
volumes: {}
networks:
default:

24
docker-compose-caddy.yaml Normal file
View File

@ -0,0 +1,24 @@
services:
caddy:
build:
context: "services/"
dockerfile: "caddy/Dockerfile"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/caddy:2.9.1-local"
command:
- "caddy"
- "run"
- "--config"
- "/etc/caddy/Caddyfile"
- "--adapter"
- "caddyfile"
ports:
- "8000:8000"
volumes:
- "./services/caddy/conf:/etc/caddy"
- "./services/tls:/opt/tls"
- "caddy_data:/data"
- "caddy_srv:/srv"
volumes:
caddy_data:
caddy_srv:

15
docker-compose-ci.yaml Normal file
View File

@ -0,0 +1,15 @@
services:
postgres:
ports: []
keycloak:
command: "echo 'NOOP'"
ports: []
restart: "no"
rabbitmq:
ports: []
include:
- path: "./services/backend/docker-compose-ci.yaml"
- path: "./services/packages/docker-compose-ci.yaml"

69
docker-compose-cloud.yaml Normal file
View File

@ -0,0 +1,69 @@
services:
postgres:
build:
context: "services/"
dockerfile: "postgres/Dockerfile"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-local"
ports:
- "5432:5432"
environment:
- "POSTGRES_PASSWORD=hotpocketm4st3r"
- "POSTGRES_USER=hotpocket"
- "POSTGRES_DB=hotpocket"
volumes:
- "postgres_data:/var/lib/postgresql/data"
networks:
default:
aliases:
- "postgres.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
keycloak:
build:
context: "services/"
dockerfile: "keycloak/Dockerfile"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-local"
command: "-v start-dev --https-certificate-file=/opt/tls/app.hotpocket.work.bthlabs.net.crt --https-certificate-key-file=/opt/tls/app.hotpocket.work.bthlabs.net.key"
ports:
- "8080:8080"
- "8443:8443"
environment:
- "KEYCLOAK_ADMIN=hotpocket"
- "KEYCLOAK_ADMIN_PASSWORD=hotpocketm4st3r"
- "KEYCLOAK_HOSTNAME=auth.hotpocket.work.bthlabs.net"
volumes:
- "./services/keycloak/fixtures:/opt/import"
- "./services/tls:/opt/tls"
- "keycloak_data:/opt/keycloak/data"
- "keycloak_var:/opt/kcsetup"
networks:
default:
aliases:
- "auth.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
rabbitmq:
build:
context: "services/"
dockerfile: "rabbitmq/Dockerfile"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-local"
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: "hotpocket"
RABBITMQ_DEFAULT_PASS: "hotpocketm4st3r"
RABBITMQ_DEFAULT_VHOST: "hotpocket"
volumes:
- "rabbitmq_data:/var/lib/rabbitmq"
networks:
default:
aliases:
- "rabbitmq.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
volumes:
postgres_data:
keycloak_data:
keycloak_var:
rabbitmq_data:

12
docker-compose.yaml Normal file
View File

@ -0,0 +1,12 @@
services: {}
include:
- path: "./docker-compose-caddy.yaml"
- path: "./docker-compose-cloud.yaml"
- path: "./services/backend/docker-compose.yaml"
- path: "./services/packages/docker-compose.yaml"
volumes: {}
networks:
default:

6
invoke.json Normal file
View File

@ -0,0 +1,6 @@
{
"run": {
"echo": true,
"pty": true
}
}

30
poetry.lock generated Normal file
View File

@ -0,0 +1,30 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "hotpocket-workspace-tools"
version = "1.0.0.dev0"
description = "HotPocket Workspace Tools"
optional = false
python-versions = "^3.12"
files = []
develop = true
[package.source]
type = "directory"
url = "services/packages/workspace_tools"
[[package]]
name = "invoke"
version = "2.2.0"
description = "Pythonic task execution"
optional = false
python-versions = ">=3.6"
files = [
{file = "invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820"},
{file = "invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "ec33c3b3ec0f988e333872bdd134c1adce0782e98512dd2484cb85009b3da6cb"

16
pyproject.toml Normal file
View File

@ -0,0 +1,16 @@
[tool.poetry]
name = "hotpocket-workspace"
version = "1.0.0"
description = "HotPocket Workspace"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0"
package-mode = false
[tool.poetry.dependencies]
python = "^3.12"
hotpocket-workspace-tools = {path = "services/packages/workspace_tools", develop = true}
invoke = "2.2.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

10
services/.dockerignore Normal file
View File

@ -0,0 +1,10 @@
_tmp/
backend/node_modules/
backend/ops/metal/
backend/hotpocket_backend/playground.py
backend/hotpocket_backend/secrets/docker/
backend/hotpocket_backend/settings/docker/
backend/hotpocket_backend/secrets/metal/
backend/hotpocket_backend/settings/metal/
backend/hotpocket_backend/static/
.envrc*

10
services/backend/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
hotpocket_backend/playground.py
hotpocket_backend/secrets/docker
hotpocket_backend/secrets/metal
hotpocket_backend/settings/docker
hotpocket_backend/settings/metal
hotpocket_backend/static/
node_modules/
run/celery-beat-schedule*
run/*.sqlite
run/uploads/

113
services/backend/Dockerfile Normal file
View File

@ -0,0 +1,113 @@
ARG APP_USER_UID=1000
ARG APP_USER_GID=1000
ARG IMAGE_ID=development.00000000
FROM docker-hosted.nexus.bthlabs.pl/hotpocket/base:build-node-20250819-01 AS development
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
USER root
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/bin/*.sh /srv/bin/
RUN chown -R ${APP_USER_UID}:${APP_USER_GID} /srv
USER app
VOLUME ["/srv/node_modules", "/srv/venv"]
FROM docker-hosted.nexus.bthlabs.pl/hotpocket/base:build-python-20250819-01 AS deployment-build
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
RUN mkdir /srv/app/hotpocket_backend /srv/packages/common /srv/packages/soa
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/hotpocket_backend/ /srv/app/hotpocket_backend/
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/poetry.lock backend/pyproject.toml backend/manage.py backend/README.md /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/common/ /srv/packages/common/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/soa/ /srv/packages/soa/
RUN poetry install --only main,deployment && \
minify -i --css-precision 0 --js-precision 0 --js-version 2022 hotpocket_backend/apps/ui/static/ui/css/hotpocket-backend*.css hotpocket_backend/apps/ui/static/ui/js/hotpocket*.js && \
./manage.py collectstatic --settings hotpocket_backend.settings.deployment.build --noinput && \
find hotpocket_backend/static/ -name "*.map*" -delete && \
rm -f hotpocket_backend/settings/deployment/build.py && \
rm -rf node_modules/
FROM docker-hosted.nexus.bthlabs.pl/hotpocket/base:base-20250819-01 AS deployment-base
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
ENV HOTPOCKET_BACKEND_IMAGE_ID=${IMAGE_ID}
ENV PYTHONPATH="/srv/local"
COPY --from=deployment-build /srv/app /srv/app
COPY --from=deployment-build /srv/packages /srv/packages
COPY --from=deployment-build /srv/venv /srv/venv
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/bin/*.sh /srv/bin/
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/deployment/gunicorn.conf.py backend/ops/deployment/gunicorn.logging.conf /srv/lib/
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv
USER root
RUN apt-get update && \
apt-get install -y libpq5 dumb-init && \
apt-get clean autoclean && \
apt-get autoremove --yes && \
rm -rf /var/lib/apt /var/lib/dpkg && \
rm -rf /home/app/.cache
USER app
ENTRYPOINT ["/srv/bin/entrypoint-deployment.sh"]
CMD ["/srv/venv/bin/gunicorn", "-c", "/srv/lib/gunicorn.conf.py", "hotpocket_backend.wsgi:application"]
FROM deployment-base AS deployment
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
ENV DJANGO_SETTINGS_MODULE=hotpocket_backend.settings.deployment.webapp
ENV HOTPOCKET_BACKEND_ENV=deployment
ENV HOTPOCKET_BACKEND_APP=webapp
VOLUME ["/srv/run", "/srv/uploads"]
FROM deployment-base AS aio
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
ENV DJANGO_SETTINGS_MODULE=hotpocket_backend.settings.aio
ENV HOTPOCKET_BACKEND_ENV=aio
ENV HOTPOCKET_BACKEND_APP=webapp
ENV HOTPOCKET_BACKEND_DEBUG=false
ENV HOTPOCKET_BACKEND_DATABASE_ENGINE=django.db.backends.sqlite3
ENV HOTPOCKET_BACKEND_DATABASE_NAME=/srv/run/hotpocket-backend-aio.sqlite
ENV HOTPOCKET_BACKEND_DATABASE_USER=
ENV HOTPOCKET_BACKEND_DATABASE_PASSWORD=
ENV HOTPOCKET_BACKEND_DATABASE_HOST=
ENV HOTPOCKET_BACKEND_DATABASE_PORT=
ENV HOTPOCKET_BACKEND_CELERY_IGNORE_RESULT=true
ENV HOTPOCKET_BACKEND_CELERY_ALWAYS_EAGER=true
ENV HOTPOCKET_BACKEND_GUNICORN_WORKERS=2
ENV HOTPOCKET_BACKEND_RUN_MIGRATIONS=true
ENV HOTPOCKET_BACKEND_UPLOADS_PATH=/srv/run/uploads
VOLUME ["/srv/run"]
FROM development AS ci
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/
RUN ln -s /srv/app/ops/docker/settings /srv/app/hotpocket_backend/settings/docker && \
ln -s /srv/app/ops/docker/secrets /srv/app/hotpocket_backend/secrets/docker && \
chown -R $APP_USER_UID:$APP_USER_GID /srv

View File

@ -0,0 +1,3 @@
# HotPocket by BTHLabs
This repository contains the _HotPocket Backend_ project.

View File

@ -0,0 +1,30 @@
services:
webapp:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-local"
environment:
DJANGO_SETTINGS_MODULE: "hotpocket_backend.settings.aio"
HOTPOCKET_BACKEND_ENV: "${HOTPOCKET_BACKEND_ENV:-aio}"
HOTPOCKET_BACKEND_APP: "webapp"
HOTPOCKET_BACKEND_DEBUG: "false"
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
HOTPOCKET_BACKEND_DATABASE_PAYLOAD: '{"engine":"django.db.backends.sqlite3","name":"/srv/run/hotpocket-backend-aio.sqlite"}'
HOTPOCKET_BACKEND_CELERY_IGNORE_RESULT: "true"
HOTPOCKET_BACKEND_CELERY_ALWAYS_EAGER: "true"
HOTPOCKET_BACKEND_GUNICORN_WORKERS: "2"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME: "hotpocket"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD: "hotpocketm4st3r"
HOTPOCKET_BACKEND_RUN_MIGRATIONS: "true"
HOTPOCKET_BACKEND_UPLOADS_PATH: "/srv/run/uploads"
volumes:
- "./run:/srv/run"
networks:
default:
aliases:
- "backend-webapp.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
stdin_open: true
tty: true

View File

@ -0,0 +1,31 @@
services:
backend-ci:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local"
command: "echo 'NOOP'"
environment:
PYTHONBREAKPOINT: "ipdb.set_trace"
PYTHONPATH: "/srv/packages/common"
DJANGO_SETTINGS_MODULE: "hotpocket_backend.settings.docker.webapp"
DJANGO_TESTING_SETTINGS_MODULE: "hotpocket_backend.settings.docker.testing"
HOTPOCKET_BACKEND_ENV: "docker"
HOTPOCKET_BACKEND_APP: "webapp"
POSTGRES_HOSTPORT: "${POSTGRES_HOST:-postgres.hotpocket.work.bthlabs.net}:${POSTGRES_PORT:-5432}"
RABBITMQ_HOSTPORT: "${RABBITMQ_HOST:-rabbitmq.hotpocket.work.bthlabs.net}:${RABBITMQ_PORT:-5672}"
# REQUESTS_CA_BUNDLE: "/srv/tls/requests_ca_bundle.pem"
RUN_POETRY_INSTALL: "true"
RUN_YARN_INSTALL: "true"
SETUP_BACKEND: "true"
SETUP_FRONTEND: "true"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
restart: "no"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "rabbitmq"

View File

@ -0,0 +1,182 @@
services:
backend-management:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command: "echo 'NOOP'"
environment: &backend-env
PYTHONBREAKPOINT: "ipdb.set_trace"
PYTHONPATH: "/srv/packages/common"
DJANGO_SETTINGS_MODULE: "hotpocket_backend.settings.docker.webapp"
DJANGO_TESTING_SETTINGS_MODULE: "hotpocket_backend.settings.docker.testing"
HOTPOCKET_BACKEND_ENV: "${HOTPOCKET_BACKEND_ENV:-docker}"
HOTPOCKET_BACKEND_APP: "webapp"
POSTGRES_HOSTPORT: "${POSTGRES_HOST:-postgres.hotpocket.work.bthlabs.net}:${POSTGRES_PORT:-5432}"
RABBITMQ_HOSTPORT: "${RABBITMQ_HOST:-rabbitmq.hotpocket.work.bthlabs.net}:${RABBITMQ_PORT:-5672}"
KEYCLOAK_HOSTPORT: "${KEYCLOAK_HOST:-auth.hotpocket.work.bthlabs.net}:${KEYCLOAK_PORT:-8080}"
REQUESTS_CA_BUNDLE: "/srv/tls/requests_ca_bundle.pem"
RUN_POETRY_INSTALL: "true"
RUN_YARN_INSTALL: "true"
SETUP_BACKEND: "true"
SETUP_FRONTEND: "true"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
restart: "no"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
backend-webapp:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command:
- "./manage.py"
- "runserver"
- "0.0.0.0:8000"
environment:
<<: *backend-env
RUN_POETRY_INSTALL: "nope"
RUN_YARN_INSTALL: "nope"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
networks:
default:
aliases:
- "backend-webapp.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
backend-admin:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command:
- "./manage.py"
- "runserver"
- "0.0.0.0:8000"
environment:
<<: *backend-env
DJANGO_SETTINGS_MODULE: "hotpocket_backend.settings.docker.admin"
HOTPOCKET_BACKEND_APP: "admin"
RUN_POETRY_INSTALL: "nope"
RUN_YARN_INSTALL: "nope"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
networks:
default:
aliases:
- "backend-admin.hotpocket.work.bthlabs.net"
restart: "unless-stopped"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
backend-celery-worker:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command:
- "celery"
- "-A"
- "hotpocket_backend.celery:app"
- "worker"
- "--loglevel=INFO"
- "-Q"
- "celery,webapp"
- "-c"
- "4"
environment:
<<: *backend-env
RUN_POETRY_INSTALL: "nope"
RUN_YARN_INSTALL: "nope"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
restart: "unless-stopped"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
backend-celery-beat:
build:
context: ".."
dockerfile: "backend/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:local"
command:
- "celery"
- "-A"
- "hotpocket_backend.celery:app"
- "beat"
- "--loglevel=INFO"
- "-s"
- "/srv/app/run/celery-beat-schedule-docker"
environment:
<<: *backend-env
RUN_POETRY_INSTALL: "nope"
RUN_YARN_INSTALL: "nope"
volumes:
- "backend_venv:/srv/venv"
- "backend_node_modules:/srv/node_modules"
- ".:/srv/app"
- "./ops/docker/settings:/srv/app/hotpocket_backend/settings/docker"
- "./ops/docker/secrets:/srv/app/hotpocket_backend/secrets/docker"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
restart: "unless-stopped"
stdin_open: true
tty: true
depends_on:
- "postgres"
- "keycloak"
- "rabbitmq"
volumes:
backend_venv:
backend_node_modules:

View File

@ -0,0 +1,67 @@
// eslint.config.js
import js from '@eslint/js';
import {defineConfig} from 'eslint/config';
import globals from 'globals';
export default defineConfig([
{
files: [
'eslint.config.js',
'hotpocket_backend/apps/ui/static/ui/js/hotpocket.*.js',
],
plugins: {
js,
},
extends: ['js/recommended'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
'no-undef': 'error',
'quotes': [
'error',
'single',
{'avoidEscape': true, 'allowTemplateLiterals': true},
],
'no-unused-vars': ['error', {'args': 'none'}],
'no-console': ['error', {'allow': ['warn', 'error']}],
'no-empty': ['error', {'allowEmptyCatch': true}],
'array-bracket-spacing': ['error', 'never'],
'block-spacing': ['error', 'always'],
'brace-style': ['error', '1tbs', {'allowSingleLine': true}],
'camelcase': ['error', {'properties': 'never'}],
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': ['error', {'before': false, 'after': true}],
'comma-style': ['error', 'last'],
'computed-property-spacing': ['error', 'never'],
'key-spacing': [
'error', {'beforeColon': false, 'afterColon': true, 'mode': 'strict'},
],
'keyword-spacing': ['error', { 'before': true, 'after': true }],
'linebreak-style': ['error', 'unix'],
'max-len': ['error', 120],
'no-multiple-empty-lines': 'error',
'no-spaced-func': 'error',
'no-trailing-spaces': 'error',
'no-unreachable': 'warn',
'no-whitespace-before-property': 'error',
'object-curly-spacing': 'off',
'one-var-declaration-per-line': ['error', 'always'],
'one-var': ['error', 'never'],
'semi-spacing': ['error', {'before': false, 'after': true}],
'semi': ['error', 'always'],
'space-before-function-paren': ['error', 'always'],
'space-before-blocks': ['error', 'always'],
'space-in-parens': ['error', 'never'],
'space-infix-ops': 'error',
'unicode-bom': ['error', 'never'],
'no-useless-escape': 'off',
'class-methods-use-this': 'off',
'no-invalid-this': 'off',
},
},
]);

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from . import _meta
from .celery import app as celery_app
__all__ = ('celery_app',)
__version__ = _meta.version

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
version = '1.0.0'

View File

@ -0,0 +1 @@
from .account import AccountAdmin # noqa: F401

View File

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from hotpocket_backend.apps.accounts.models import Account
class AccountAdmin(UserAdmin):
list_display = (*UserAdmin.list_display, 'is_active')
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
admin.site.register(Account, AccountAdmin)

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'accounts'
name = 'hotpocket_backend.apps.accounts'
verbose_name = _('Accounts')

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from hotpocket_backend.apps.accounts.types import PAccount
def is_authenticated_and_active_account(account: PAccount) -> bool:
return all((
account.is_authenticated,
account.is_active,
))

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.http import HttpRequest
from hotpocket_backend.apps.core.conf import settings
def hotpocket_oidc(request: HttpRequest) -> dict:
return {
'HOTPOCKET_OIDC_IS_ENABLED': settings.SECRETS.OIDC.is_enabled,
'HOTPOCKET_OIDC_DISPLAY_NAME': settings.SECRETS.OIDC.display_name,
}
def auth_settings(request: HttpRequest) -> dict:
return {
'MODEL_AUTH_IS_DISABLED': settings.MODEL_AUTH_IS_DISABLED,
}

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import user_passes_test
from .checks import is_authenticated_and_active_account
def account_required(function=None,
redirect_field_name=REDIRECT_FIELD_NAME,
login_url=None,
):
actual_decorator = user_passes_test(
is_authenticated_and_active_account,
login_url=login_url,
redirect_field_name=redirect_field_name,
)
if function:
return actual_decorator(function)
return actual_decorator

View File

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from argparse import ArgumentParser
import logging
from django.core.management import BaseCommand
import django.db
from hotpocket_backend.apps.accounts.models import Account
LOGGER = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Create initial Account'
def add_arguments(self, parser: ArgumentParser):
parser.add_argument(
'username',
help='Username for the Account',
)
parser.add_argument(
'password',
help='Password for the Account',
)
parser.add_argument(
'-d', '--dry-run', action='store_true', default=False,
help='Dry run',
)
def handle(self, *args, **options):
LOGGER.debug('args=`%s` options=`%s`', args, options)
username = options.get('username')
password = options.get('password')
dry_run = options.get('dry_run', False)
with django.db.transaction.atomic():
current_account = Account.objects.filter(username=username).first()
if current_account is not None:
LOGGER.info(
'Account already exists: account=`%s`', current_account,
)
return
account = Account.objects.create(
username=username,
first_name=username,
is_superuser=True,
is_staff=True,
)
account.set_password(password)
account.save()
LOGGER.info(
'Account created: account=`%s`', account,
)
if dry_run is True:
raise RuntimeError('DRY RUN')

View File

@ -0,0 +1,44 @@
# Generated by Django 5.2.3 on 2025-06-30 20:37
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
import uuid6
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('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')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('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')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('id', models.UUIDField(default=uuid6.uuid7, primary_key=True, serialize=False)),
('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='account_set', related_query_name='account', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='account_set', related_query_name='account', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'Account',
'verbose_name_plural': 'Accounts',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-07-10 19:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='account',
name='settings',
field=models.JSONField(default=dict),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-08-02 19:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_account_settings'),
]
operations = [
migrations.AddField(
model_name='account',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.3 on 2025-08-08 19:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_account_updated_at'),
]
operations = [
migrations.AlterField(
model_name='account',
name='settings',
field=models.JSONField(db_column='settings', default=dict),
),
migrations.RenameField(
model_name='account',
old_name='settings',
new_name='raw_settings',
),
]

View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.auth.mixins import AccessMixin
from .checks import is_authenticated_and_active_account
class AccountRequiredMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if is_authenticated_and_active_account(self.request.user) is False:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)

View File

@ -0,0 +1 @@
from .account import Account # noqa: F401

View File

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.auth.models import (
AbstractUser,
Group,
Permission,
UserManager,
)
from django.db import models
from django.utils.translation import gettext_lazy as _
import uuid6
class ActiveAccountsManager(models.Manager):
def get_queryset(self) -> models.QuerySet:
return super().get_queryset().filter(
is_active=True,
is_staff=False,
)
class Account(AbstractUser):
id = models.UUIDField(
null=False, blank=False, default=uuid6.uuid7, primary_key=True,
)
groups = models.ManyToManyField(
Group,
verbose_name=_('groups'),
blank=True,
help_text=_(
(
'The groups this user belongs to. A user will get all '
'permissions granted to each of their groups.'
),
),
related_name='account_set',
related_query_name='account',
)
user_permissions = models.ManyToManyField(
Permission,
verbose_name=_('user permissions'),
blank=True,
help_text=_('Specific permissions for this user.'),
related_name='account_set',
related_query_name='account',
)
raw_settings = models.JSONField(default=dict, db_column='settings')
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
active_accounts = ActiveAccountsManager()
class Meta:
verbose_name = _('Account')
verbose_name_plural = _('Accounts')
def __str__(self) -> str:
return f'<Account pk={self.pk} username={self.username}>'
@property
def settings(self) -> dict:
result = {**self.raw_settings}
auto_load_embeds = result.get('auto_load_embeds', None)
if isinstance(auto_load_embeds, str) is True:
result['auto_load_embeds'] = (auto_load_embeds == 'True')
else:
result['auto_load_embeds'] = auto_load_embeds
return result

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from social_core.backends.open_id_connect import OpenIdConnectAuth
LOGGER = logging.getLogger(__name__)
class HotPocketOpenIdConnectAuth(OpenIdConnectAuth):
name = 'hotpocket_oidc'
def _get_roles_from_response(response) -> list[str]:
from hotpocket_backend.apps.core.conf import settings
return response.\
get('resource_access', {}).\
get(settings.SECRETS.OIDC.key, {}).\
get('roles', [])
def set_user_is_staff(strategy, details, response, user=None, *args, **kwargs):
if user is None:
return None
roles = _get_roles_from_response(response)
user.is_staff = 'staff' in roles
strategy.storage.user.changed(user)
def set_user_is_superuser(strategy, details, response, user=None, *args, **kwargs):
if user is None:
return None
roles = _get_roles_from_response(response)
user.is_superuser = 'superuser' in roles
strategy.storage.user.changed(user)

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
import uuid
class PAccount(typing.Protocol):
id: uuid.UUID
username: str
first_name: str
last_name: str
email: str
is_active: bool
is_anonymous: bool
is_authenticated: bool
pk: uuid.UUID

View File

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib import admin
from django.contrib.admin.apps import AdminConfig
from django.utils.translation import gettext_lazy as _
class HotPocketAdminSite(admin.AdminSite):
site_header = 'HotPocket'
site_title = 'HotPocket by BTHLabs'
index_title = _('Home')
login_template = 'core/admin/login.html'
class HotPocketAdminConfig(AdminConfig):
default_site = 'hotpocket_backend.apps.admin.HotPocketAdminSite'

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
class BotConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'bot'
name = 'hotpocket_backend.apps.bot'
verbose_name = _('Bot')
def ready(self):
super().ready()
try:
from hotpocket_backend.apps.bot import conf
conf.bot_settings = conf.from_django_settings()
except Exception as exception:
raise ImproperlyConfigured('Invalid bot settings') from exception

View File

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import dataclasses
from .types import PStrategy
DEFAULT_STRATEGY = 'hotpocket_backend.apps.bot.strategy.basic:BasicStrategy'
DEFAULT_BANNED_HOSTNAMES = [
# YT returns dummy data when I try to fetch the page and extract
# metadata. I'd have to use Google APIs for that and it's 11:30 PM...
'youtube.com',
'youtu.be',
# Reddit's response is too generic to pull any useful info from it.
# Since they forced Apollo to shut down, I refuse to even think about
# interacting with their API :P.
'reddit.com',
# Twitter, amirite?
'twitter.com',
't.co',
'x.com',
]
@dataclasses.dataclass(kw_only=True)
class Settings:
STRATEGY: str
BANNED_HOSTNAMES: list[str]
def get_strategy(self, *, url: str) -> PStrategy:
from hotpocket_common.loader import load_module_attribute
strategy = load_module_attribute(self.STRATEGY)
return strategy(url)
def from_django_settings() -> Settings:
from django.conf import settings
return Settings(
STRATEGY=getattr(
settings,
'HOTPOCKET_BOT_STRATEGY',
DEFAULT_STRATEGY,
),
BANNED_HOSTNAMES=getattr(
settings,
'HOTPOCKET_BOT_BANNED_HOSTNAMES',
DEFAULT_BANNED_HOSTNAMES,
),
)
bot_settings: Settings = None # type: ignore[assignment]

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import pydantic
class FetchResult(pydantic.BaseModel):
status_code: int
content: bytes
content_type: str | None
encoding: str

View File

@ -0,0 +1 @@
from .bot import BotService # noqa: F401

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from hotpocket_backend.apps.bot.conf import bot_settings
from hotpocket_backend.apps.bot.types import PStrategy
from hotpocket_soa.dto import BotResultOut
class BotService:
def is_netloc_banned(self, *, url: str) -> bool:
strategy: PStrategy = bot_settings.get_strategy(url=url)
return strategy.is_netloc_banned()
def handle(self, *, url: str) -> BotResultOut:
strategy: PStrategy = bot_settings.get_strategy(url=url)
return strategy.run()

View File

@ -0,0 +1 @@
from .basic import BasicStrategy # noqa: F401

View File

@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import abc
import logging
from pyquery import PyQuery
import requests
from hotpocket_backend._meta import version as backend_version
from hotpocket_backend.apps.bot.conf import bot_settings
from hotpocket_backend.apps.bot.dto.strategy import FetchResult
from hotpocket_common.url import URL
from hotpocket_soa.dto import BotResultOut
LOGGER = logging.getLogger(__name__)
class Strategy(abc.ABC):
class StrategyError(Exception):
pass
class FetchError(StrategyError):
pass
class RuntimeError(StrategyError):
pass
USER_AGENT = (
'Mozilla/5.0 '
'('
'compatible; '
f'BTHLabsHotPocketBot/{backend_version}; '
'+https://hotpocket.app/bot.txt'
')'
)
TITLE_TAG_SELECTORS = [
'head > meta[name=title]',
'head > meta[property="og:title"]',
'head > title',
]
DESCRIPTION_TAG_SELECTORS = [
'head > meta[property="og:description"]',
'head > meta[name=description]',
]
def __init__(self, url: str):
super().__init__()
self.url = url
self.parsed_url = URL(self.url)
self.logger = self.get_logger()
def get_logger(self) -> logging.Logger:
return LOGGER.getChild(self.__class__.__name__)
def is_netloc_banned(self) -> bool:
result = False
for banned_netloc in bot_settings.BANNED_HOSTNAMES:
hostname = self.parsed_url.hostname
if hostname is not None and hostname.endswith(banned_netloc) is True:
result = True
break
return result
def fetch(self, url: str) -> FetchResult:
try:
response = requests.request(
'GET',
url,
headers={
'User-Agent': self.USER_AGENT,
},
)
response.raise_for_status()
return FetchResult.model_validate(dict(
status_code=response.status_code,
content=response.content,
content_type=response.headers.get('Content-Type', None),
encoding=response.encoding or response.apparent_encoding,
))
except Exception as exception:
self.logger.error(
'Fetch error: %s', exception, exc_info=True,
)
raise self.FetchError() from exception
def extract_title_and_description_from_html(self, content: str) -> tuple[str | None, str | None]:
dom = PyQuery(content)
title: str | None = None
description: str | None = None
for selector in self.TITLE_TAG_SELECTORS:
title_tags = dom.find(selector)
if len(title_tags) > 0:
title_tag = PyQuery(title_tags[0])
if title_tag.is_('meta'):
title = title_tag.attr('content')
else:
title = title_tag.text()
break
for selector in self.DESCRIPTION_TAG_SELECTORS:
description_tags = dom.find(selector)
if len(description_tags) > 0:
description = PyQuery(description_tags[0]).attr('content')
break
if description is None:
try:
description = PyQuery(dom.find('p')[0]).text()
except IndexError:
pass
return (
title.strip() or None
if title is not None
else None,
description.strip() or None
if description is not None
else None,
)
def run(self) -> BotResultOut:
result = BotResultOut.model_validate(dict(
title=None,
description=None,
is_netloc_banned=False,
))
result.is_netloc_banned = self.is_netloc_banned()
if result.is_netloc_banned is False:
fetch_result = self.fetch(self.url)
try:
assert fetch_result.content is not None, (
'Received empty content'
)
assert fetch_result.content_type is not None, (
'Unable to determine the content type'
)
assert fetch_result.content_type.startswith('text/html') is True, (
f'Unsupported content type: `{fetch_result.content_type}`'
)
except AssertionError as exception:
self.logger.error(
'Unprocessable fetch result: %s', exception, exc_info=exception,
)
raise self.RuntimeError(exception.args[0]) from exception
try:
decoded_content = fetch_result.content.decode(fetch_result.encoding)
title, description = self.extract_title_and_description_from_html(
decoded_content,
)
result.title = title
result.description = description
except Exception as exception:
self.logger.error(
'Processing error: %s', exception, exc_info=exception,
)
raise self.RuntimeError() from exception
else:
self.logger.debug('Skipping banned netloc: url=`%s`', self.url)
return result

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from .base import Strategy
class BasicStrategy(Strategy):
pass

View File

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from hotpocket_soa.dto import BotResultOut
class PStrategy(typing.Protocol):
def __init__(self, url: str):
...
def is_netloc_banned(self) -> bool:
...
def run(self) -> BotResultOut:
...

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'core'
name = 'hotpocket_backend.apps.core'
verbose_name = _('Core')

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.conf import settings as django_settings
from .types import PSettings
settings: PSettings = django_settings

View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import contextvars
from hotpocket_common.constants import NULL_UUID
REQUEST_ID: contextvars.ContextVar[str] = contextvars.ContextVar(
'request_id', default=str(NULL_UUID),
)
def get_request_id() -> str:
return REQUEST_ID.get()

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from hotpocket_backend.apps.core.context import get_request_id
class RequestIDFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = get_request_id()
return True

View File

@ -0,0 +1 @@
from .request_id import RequestIDMiddleware # noqa: F401

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
from django.http import HttpHeaders, HttpRequest
from hotpocket_backend.apps.core.context import REQUEST_ID
class RequestIDMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpHeaders:
request_id = str(uuid.uuid4())
REQUEST_ID.set(request_id)
response = self.get_response(request)
response["X-RequestID"] = REQUEST_ID.get()
return response

View File

@ -0,0 +1 @@
from .base import Model # noqa: F401

View File

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.db import models
from django.utils.timezone import now
import uuid6
class Model(models.Model):
id = models.UUIDField(
primary_key=True,
null=False,
default=uuid6.uuid7,
editable=False,
)
account_uuid = models.UUIDField(
blank=False, null=False, default=None, db_index=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(
blank=True,
null=True,
default=None,
db_index=True,
editable=False,
)
class Meta:
abstract = True
@property
def is_active(self) -> bool:
return self.deleted_at is None
def save(self,
force_insert=False,
force_update=False,
using=None,
update_fields=None,
):
self.full_clean()
super().save(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
)
def soft_delete(self, save=True) -> None:
self.deleted_at = now()
if save is True:
self.save()

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from hotpocket_common.loader import load_module_attribute
from .conf import settings
def get_adapter(setting: str, default: str) -> typing.Any:
import_path = getattr(settings, setting, default)
return load_module_attribute(import_path)

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from celery import shared_task
LOGGER = logging.getLogger(__name__)
@shared_task
def ping():
LOGGER.info('PONG')
@shared_task(bind=True)
def debug_request(self):
LOGGER.warning(
'request.id=`%s` request.properties=`%s`',
self.request.id,
self.request.properties,
)

View File

@ -0,0 +1,83 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block title %}{% if form.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %}
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/login.css" %}">
{{ form.media }}
{% endblock %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block nav-sidebar %}{% endblock %}
{% block content_title %}{% endblock %}
{% block nav-breadcrumbs %}{% endblock %}
{% block content %}
{% if form.errors and not form.non_field_errors %}
<p class="errornote">
{% blocktranslate count counter=form.errors.items|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %}
</p>
{% endif %}
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<p class="errornote">
{{ error }}
</p>
{% endfor %}
{% endif %}
<div id="content-main">
{% if user.is_authenticated %}
<p class="errornote">
{% blocktranslate trimmed %}
You are authenticated as {{ username }}, but are not authorized to
access this page. Would you like to login to a different account?
{% endblocktranslate %}
</p>
{% endif %}
<form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
{% if not MODEL_AUTH_IS_DISABLED %}
<div class="form-row">
{{ form.username.errors }}
{{ form.username.label_tag }} {{ form.username }}
</div>
<div class="form-row">
{{ form.password.errors }}
{{ form.password.label_tag }} {{ form.password }}
<input type="hidden" name="next" value="{{ next }}">
</div>
{% url 'admin_password_reset' as password_reset_url %}
{% if password_reset_url %}
<div class="password-reset-link">
<a href="{{ password_reset_url }}">{% translate 'Forgotten your login credentials?' %}</a>
</div>
{% endif %}
<div class="submit-row">
<input type="submit" value="{% translate 'Log in' %}">
</div>
{% endif %}
{% if HOTPOCKET_OIDC_IS_ENABLED %}
<div class="submit-row">
<a
class="button"
href="{% url 'social:begin' 'hotpocket_oidc' %}"
style="display: block;"
>
{% blocktranslate %}Log in with {{ HOTPOCKET_OIDC_DISPLAY_NAME }}{% endblocktranslate %}
</a>
</div>
{% endif %}
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import pathlib
import typing
from hotpocket_backend.secrets.admin import AdminSecrets
from hotpocket_backend.secrets.webapp import WebAppSecrets
from hotpocket_common.constants import App, Env
class PSettings(typing.Protocol):
DEBUG: bool
TESTING: bool
ALLOWED_HOSTS: list[str]
SECRET_KEY: str
APP: App
ENV: Env
SECRETS: AdminSecrets | WebAppSecrets
MODEL_AUTH_IS_DISABLED: bool
SITE_TITLE: str
SITE_SHORT_TITLE: str
IMAGE_ID: str
SAVES_SAVE_ADAPTER: str
SAVES_ASSOCIATION_ADAPTER: str
UPLOADS_PATH: pathlib.Path

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class HTMXConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'htmx'
name = 'hotpocket_backend.apps.htmx'
verbose_name = _('HTMX')

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib.messages.constants import ( # noqa: F401
DEBUG,
DEFAULT_TAGS,
ERROR,
INFO,
SUCCESS,
WARNING,
)
from django.http import HttpRequest, HttpResponse
from django_htmx.http import trigger_client_event
def add_htmx_message(*,
request: HttpRequest,
response: HttpResponse,
level: str,
message: str,
extra_tags: str = '',
fail_silently: bool = False,
):
if not request.htmx:
if fail_silently is False:
raise RuntimeError(
"This doesn't look like an HTMX request: request=`%s`",
request,
)
else:
trigger_client_event(
response,
'HotPocket:UI:Messages:addMessage',
{
'level': DEFAULT_TAGS.get(level, DEFAULT_TAGS[INFO]),
'message': message,
'extra_tags': extra_tags,
},
after='swap',
)

View File

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
from django.db import models
from hotpocket_backend.apps.saves.models import Save
from hotpocket_common.constants import AssociationsSearchMode
from hotpocket_soa.dto.associations import AssociationsQuery
class SaveAdapter:
def get_for_processing(self, *, pk: uuid.UUID) -> Save | None:
return Save.active_objects.get(pk=pk)
class AssociationAdapter:
def get_search_term_filters(self, *, query: AssociationsQuery) -> list[models.Q]:
# I suck at naming things, LOL.
result = [
(
models.Q(target__url__icontains=query.search)
|
models.Q(target_title__icontains=query.search)
|
models.Q(target__title__icontains=query.search)
|
models.Q(target_description__icontains=query.search)
|
models.Q(target__description__icontains=query.search)
),
]
return result
def get_search_filters(self, *, query: AssociationsQuery) -> list[models.Q]:
result = [
models.Q(account_uuid=query.account_uuid),
models.Q(target__deleted_at__isnull=True),
]
if query.mode == AssociationsSearchMode.ARCHIVED:
result.append(models.Q(archived_at__isnull=False))
else:
result.append(models.Q(archived_at__isnull=True))
match query.mode:
case AssociationsSearchMode.STARRED:
result.append(models.Q(starred_at__isnull=False))
if query.before is not None:
result.append(models.Q(pk__lt=query.before))
if query.after is not None:
result.append(models.Q(pk__gte=query.after))
if query.search is not None:
result.extend(self.get_search_term_filters(query=query))
return result

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from .base import AssociationAdapter, SaveAdapter
class BasicSaveAdapter(SaveAdapter):
pass
class BasicAssociationAdapter(AssociationAdapter):
pass

View File

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
import uuid
import django.db
from django.db import models
from hotpocket_backend.apps.saves.models import Save
from hotpocket_common.db import postgres # noqa: F401
from hotpocket_soa.dto.associations import AssociationsQuery
from .base import AssociationAdapter, SaveAdapter
LOGGER = logging.getLogger(__name__)
class PostgresSaveAdapter(SaveAdapter):
ROW_LOCKED_MESSAGE = 'could not obtain lock on row in relation "saves_save"'
def get_for_processing(self, *, pk: uuid.UUID) -> Save | None:
try:
return Save.active_objects.select_for_update(nowait=True).get(pk=pk)
except django.db.utils.OperationalError as exception:
if exception.args[0].startswith(self.ROW_LOCKED_MESSAGE) is True:
LOGGER.info('Trying to process a locked save: pk=`%s`', pk)
return None
raise exception
class PostgresAssociationAdapter(AssociationAdapter):
def get_search_term_filters(self, *, query: AssociationsQuery) -> list[models.Q]:
result = [
(
models.Q(target__url__ilike=query.search)
|
models.Q(target_title__ilike=query.search)
|
models.Q(target__title__ilike=query.search)
|
models.Q(target_description__ilike=query.search)
|
models.Q(target__description__ilike=query.search)
),
]
return result

View File

@ -0,0 +1 @@
from . import save # noqa: F401

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from hotpocket_backend.apps.saves.models import Save
class SaveAdmin(admin.ModelAdmin):
list_display = (
'pk', 'key', 'account_uuid', 'created_at', 'render_is_active',
)
ordering = ['-created_at']
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
@admin.display(
description=_('Is Active?'), boolean=True, ordering='-deleted_at',
)
def render_is_active(self, obj: Save | None = None) -> bool | None:
if obj is None:
return None
return obj.is_active
admin.site.register(Save, SaveAdmin)

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
label = 'saves'
name = 'hotpocket_backend.apps.saves'
verbose_name = _('Saves')

Some files were not shown because too many files have changed in this diff Show More