This commit is contained in:
commit
b4338e2769
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal 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
70
.gitea/workflows/ci.yaml
Normal 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
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
.envrc*
|
||||
.ipythonhome/
|
202
LICENSE
Normal file
202
LICENSE
Normal 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
88
NOTICE.txt
Normal 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
167
README.md
Normal 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
2
deployment/aio/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
run/*.sqlite
|
||||
uploads/
|
12
deployment/aio/docker-compose.yaml
Normal file
12
deployment/aio/docker-compose.yaml
Normal 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"
|
0
deployment/aio/run/.placeholder
Normal file
0
deployment/aio/run/.placeholder
Normal file
2
deployment/fullstack/.gitignore
vendored
Normal file
2
deployment/fullstack/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
run/celery-beat-schedule
|
||||
uploads/*.csv
|
77
deployment/fullstack/docker-compose.yaml
Normal file
77
deployment/fullstack/docker-compose.yaml
Normal 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"
|
0
deployment/fullstack/run/.placeholder
Normal file
0
deployment/fullstack/run/.placeholder
Normal file
0
deployment/fullstack/uploads/.placeholder
Normal file
0
deployment/fullstack/uploads/.placeholder
Normal file
111
docker-bake.json
Normal file
111
docker-bake.json
Normal 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
10
docker-compose-aio.yaml
Normal 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
24
docker-compose-caddy.yaml
Normal 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
15
docker-compose-ci.yaml
Normal 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
69
docker-compose-cloud.yaml
Normal 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
12
docker-compose.yaml
Normal 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
6
invoke.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"run": {
|
||||
"echo": true,
|
||||
"pty": true
|
||||
}
|
||||
}
|
30
poetry.lock
generated
Normal file
30
poetry.lock
generated
Normal 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
16
pyproject.toml
Normal 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
10
services/.dockerignore
Normal 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
10
services/backend/.gitignore
vendored
Normal 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
113
services/backend/Dockerfile
Normal 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
|
3
services/backend/README.md
Normal file
3
services/backend/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# HotPocket by BTHLabs
|
||||
|
||||
This repository contains the _HotPocket Backend_ project.
|
30
services/backend/docker-compose-aio.yaml
Normal file
30
services/backend/docker-compose-aio.yaml
Normal 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
|
31
services/backend/docker-compose-ci.yaml
Normal file
31
services/backend/docker-compose-ci.yaml
Normal 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"
|
182
services/backend/docker-compose.yaml
Normal file
182
services/backend/docker-compose.yaml
Normal 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:
|
67
services/backend/eslint.config.js
Normal file
67
services/backend/eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
]);
|
8
services/backend/hotpocket_backend/__init__.py
Normal file
8
services/backend/hotpocket_backend/__init__.py
Normal 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
|
4
services/backend/hotpocket_backend/_meta.py
Normal file
4
services/backend/hotpocket_backend/_meta.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
version = '1.0.0'
|
0
services/backend/hotpocket_backend/apps/__init__.py
Normal file
0
services/backend/hotpocket_backend/apps/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .account import AccountAdmin # noqa: F401
|
|
@ -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)
|
12
services/backend/hotpocket_backend/apps/accounts/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/accounts/apps.py
Normal 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')
|
11
services/backend/hotpocket_backend/apps/accounts/checks.py
Normal file
11
services/backend/hotpocket_backend/apps/accounts/checks.py
Normal 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,
|
||||
))
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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')
|
|
@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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',
|
||||
),
|
||||
]
|
14
services/backend/hotpocket_backend/apps/accounts/mixins.py
Normal file
14
services/backend/hotpocket_backend/apps/accounts/mixins.py
Normal 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)
|
|
@ -0,0 +1 @@
|
|||
from .account import Account # noqa: F401
|
|
@ -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
|
40
services/backend/hotpocket_backend/apps/accounts/social.py
Normal file
40
services/backend/hotpocket_backend/apps/accounts/social.py
Normal 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)
|
18
services/backend/hotpocket_backend/apps/accounts/types.py
Normal file
18
services/backend/hotpocket_backend/apps/accounts/types.py
Normal 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
|
17
services/backend/hotpocket_backend/apps/admin.py
Normal file
17
services/backend/hotpocket_backend/apps/admin.py
Normal 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'
|
22
services/backend/hotpocket_backend/apps/bot/apps.py
Normal file
22
services/backend/hotpocket_backend/apps/bot/apps.py
Normal 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
|
54
services/backend/hotpocket_backend/apps/bot/conf.py
Normal file
54
services/backend/hotpocket_backend/apps/bot/conf.py
Normal 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]
|
11
services/backend/hotpocket_backend/apps/bot/dto/strategy.py
Normal file
11
services/backend/hotpocket_backend/apps/bot/dto/strategy.py
Normal 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
|
|
@ -0,0 +1 @@
|
|||
from .bot import BotService # noqa: F401
|
16
services/backend/hotpocket_backend/apps/bot/services/bot.py
Normal file
16
services/backend/hotpocket_backend/apps/bot/services/bot.py
Normal 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()
|
|
@ -0,0 +1 @@
|
|||
from .basic import BasicStrategy # noqa: F401
|
174
services/backend/hotpocket_backend/apps/bot/strategy/base.py
Normal file
174
services/backend/hotpocket_backend/apps/bot/strategy/base.py
Normal 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
|
|
@ -0,0 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import Strategy
|
||||
|
||||
|
||||
class BasicStrategy(Strategy):
|
||||
pass
|
17
services/backend/hotpocket_backend/apps/bot/types.py
Normal file
17
services/backend/hotpocket_backend/apps/bot/types.py
Normal 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:
|
||||
...
|
12
services/backend/hotpocket_backend/apps/core/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/core/apps.py
Normal 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')
|
8
services/backend/hotpocket_backend/apps/core/conf.py
Normal file
8
services/backend/hotpocket_backend/apps/core/conf.py
Normal 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
|
14
services/backend/hotpocket_backend/apps/core/context.py
Normal file
14
services/backend/hotpocket_backend/apps/core/context.py
Normal 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()
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
from .request_id import RequestIDMiddleware # noqa: F401
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
from .base import Model # noqa: F401
|
54
services/backend/hotpocket_backend/apps/core/models/base.py
Normal file
54
services/backend/hotpocket_backend/apps/core/models/base.py
Normal 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()
|
13
services/backend/hotpocket_backend/apps/core/services.py
Normal file
13
services/backend/hotpocket_backend/apps/core/services.py
Normal 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)
|
22
services/backend/hotpocket_backend/apps/core/tasks.py
Normal file
22
services/backend/hotpocket_backend/apps/core/tasks.py
Normal 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,
|
||||
)
|
|
@ -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 %}
|
32
services/backend/hotpocket_backend/apps/core/types.py
Normal file
32
services/backend/hotpocket_backend/apps/core/types.py
Normal 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
|
12
services/backend/hotpocket_backend/apps/htmx/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/htmx/apps.py
Normal 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')
|
40
services/backend/hotpocket_backend/apps/htmx/messages.py
Normal file
40
services/backend/hotpocket_backend/apps/htmx/messages.py
Normal 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',
|
||||
)
|
|
@ -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
|
|
@ -0,0 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import AssociationAdapter, SaveAdapter
|
||||
|
||||
|
||||
class BasicSaveAdapter(SaveAdapter):
|
||||
pass
|
||||
|
||||
|
||||
class BasicAssociationAdapter(AssociationAdapter):
|
||||
pass
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
from . import save # noqa: F401
|
29
services/backend/hotpocket_backend/apps/saves/admin/save.py
Normal file
29
services/backend/hotpocket_backend/apps/saves/admin/save.py
Normal 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)
|
12
services/backend/hotpocket_backend/apps/saves/apps.py
Normal file
12
services/backend/hotpocket_backend/apps/saves/apps.py
Normal 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
Loading…
Reference in New Issue
Block a user