initial commit
This commit is contained in:
commit
164fa61ad2
40 changed files with 1263 additions and 0 deletions
209
.gitignore
vendored
Normal file
209
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Created by https://www.toptal.com/developers/gitignore/api/vim,venv,python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=vim,venv,python
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### venv ###
|
||||
# Virtualenv
|
||||
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
|
||||
[Bb]in
|
||||
[Ii]nclude
|
||||
[Ll]ib
|
||||
[Ll]ib64
|
||||
[Ll]ocal
|
||||
[Ss]cripts
|
||||
pyvenv.cfg
|
||||
pip-selfcheck.json
|
||||
|
||||
### Vim ###
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
!*.svg # comment out if you don't need vector files
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
*~
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
# Persistent undo
|
||||
[._]*.un~
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/vim,venv,python
|
||||
7
README.md
Normal file
7
README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Community Backup
|
||||
|
||||
A website to manage backup repositories.
|
||||
|
||||
## Installation
|
||||
|
||||
TODO
|
||||
0
community_backup/community_backup/__init__.py
Normal file
0
community_backup/community_backup/__init__.py
Normal file
16
community_backup/community_backup/asgi.py
Normal file
16
community_backup/community_backup/asgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for community_backup project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "community_backup.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
2
community_backup/community_backup/configuration.py
Normal file
2
community_backup/community_backup/configuration.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# enter your overrides here
|
||||
print("foo")
|
||||
130
community_backup/community_backup/settings.py
Normal file
130
community_backup/community_backup/settings.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""
|
||||
Django settings for community_backup project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.3.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-_53=1gp5m=j+ienpcguini#xczk^d+&jx*#@z+2837+_-0=+$z"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"*",
|
||||
]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"webui",
|
||||
"crispy_forms",
|
||||
"crispy_bootstrap5",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "community_backup.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "community_backup.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
|
||||
|
||||
from .configuration import *
|
||||
25
community_backup/community_backup/urls.py
Normal file
25
community_backup/community_backup/urls.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""
|
||||
URL configuration for community_backup project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path("", include("webui.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/", include("django.contrib.auth.urls"))
|
||||
]
|
||||
16
community_backup/community_backup/wsgi.py
Normal file
16
community_backup/community_backup/wsgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for community_backup project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "community_backup.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
23
community_backup/manage.py
Executable file
23
community_backup/manage.py
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "community_backup.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
community_backup/webui/__init__.py
Normal file
0
community_backup/webui/__init__.py
Normal file
7
community_backup/webui/admin.py
Normal file
7
community_backup/webui/admin.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import BorgRepository, Voucher
|
||||
|
||||
# Register your models here.
|
||||
admin.site.register(BorgRepository)
|
||||
admin.site.register(Voucher)
|
||||
5
community_backup/webui/apps.py
Normal file
5
community_backup/webui/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WebuiConfig(AppConfig):
|
||||
name = "webui"
|
||||
157
community_backup/webui/deployments.py
Normal file
157
community_backup/webui/deployments.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
from pathlib import Path
|
||||
from subprocess import run
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
from logging import getLogger
|
||||
import shutil
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from .models import BorgRepository
|
||||
|
||||
logger = getLogger("deployments")
|
||||
|
||||
# sync users
|
||||
|
||||
|
||||
def get_users(user_prefix: str = "u-") -> dict[str, dict[str, Any]]:
|
||||
|
||||
passwd = Path("/etc/passwd").read_text()
|
||||
output = dict()
|
||||
for line in passwd.splitlines():
|
||||
name, _, _, _, _, homedir, _ = line.split(":")
|
||||
if not name.startswith(user_prefix):
|
||||
continue
|
||||
|
||||
output[name] = {"homedir": Path(homedir)}
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def cleanup_users(
|
||||
usernames: set[str],
|
||||
system_users: dict[str, dict[str, Any]],
|
||||
dry_run=False,
|
||||
):
|
||||
"""Cleans up all unused user accounts and directories on the system.
|
||||
|
||||
usernames is the set of users that should exist.
|
||||
system_users contains all the users that exist (with the correct prefix)"""
|
||||
|
||||
base_dir = Path("/home/")
|
||||
|
||||
# cleanup users
|
||||
for user in system_users.keys():
|
||||
if user not in usernames:
|
||||
logger.info("Deleting user '%(user)s'.")
|
||||
print(f"Deleting user '{user}'.")
|
||||
if not dry_run:
|
||||
run(["userdel", user])
|
||||
pass
|
||||
|
||||
# cleanup directories
|
||||
for dir in base_dir.iterdir():
|
||||
if dir.is_dir() and dir.name.startswith("u-") and dir.name not in usernames:
|
||||
logger.info("Deleting home directory '%(dir)s'")
|
||||
print(f"Deleting home directory '{dir}'")
|
||||
if not dry_run:
|
||||
shutil.rmtree(dir)
|
||||
pass
|
||||
|
||||
|
||||
def create_users(
|
||||
usernames: set[str],
|
||||
system_users: dict[str, dict[str, Any]],
|
||||
dry_run=False,
|
||||
):
|
||||
|
||||
for username in usernames:
|
||||
if username not in system_users.keys():
|
||||
logger.info("Adding user '%(username)s'")
|
||||
print(f"Adding user '{username}'")
|
||||
if not dry_run:
|
||||
add_user(username)
|
||||
|
||||
|
||||
def add_user(username: str):
|
||||
|
||||
users = get_users()
|
||||
assert username not in users.keys(), "User already exists"
|
||||
|
||||
run(["useradd", "--no-user-group", "--create-home", username], check=True)
|
||||
|
||||
|
||||
def sync_users(dry_run=False):
|
||||
|
||||
# gather data
|
||||
users = User.objects.all()
|
||||
usernames = {f"u-{user.pk}" for user in users}
|
||||
system_users = get_users()
|
||||
|
||||
# clean up old users
|
||||
cleanup_users(usernames=usernames, system_users=system_users, dry_run=dry_run)
|
||||
|
||||
# create the new users
|
||||
create_users(usernames=usernames, system_users=system_users, dry_run=dry_run)
|
||||
|
||||
for user in users:
|
||||
username = f"u-{user.pk}"
|
||||
sync_repos(
|
||||
username=username,
|
||||
user=user,
|
||||
system_user=system_users[username],
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
# sync authorized_keys
|
||||
def sync_repos(username: str, user: User, system_user: dict[str, Any], dry_run=False):
|
||||
|
||||
repos = BorgRepository.objects.filter(user=user)
|
||||
|
||||
repos_by_key = defaultdict(list)
|
||||
for repo in repos:
|
||||
repos_by_key[repo.key].append(repo)
|
||||
|
||||
homedir = system_user["homedir"]
|
||||
|
||||
# create .ssh directory
|
||||
ssh_dir = homedir / ".ssh"
|
||||
ssh_dir.mkdir(
|
||||
mode=0o750,
|
||||
exist_ok=True,
|
||||
parents=True,
|
||||
)
|
||||
|
||||
# create authorized_keys file
|
||||
authorized_keys = ssh_dir / "authorized_keys"
|
||||
|
||||
commands = []
|
||||
for key, repos in repos_by_key.items():
|
||||
repo_paths = [
|
||||
f"--restrict-to-repository {str(system_user['homedir'] / repo.name)}"
|
||||
for repo in repos
|
||||
]
|
||||
commands.append(
|
||||
f"""command="borg serve {" ".join(repo_paths)} --quota=500G",restrict ssh-rsa {key}"""
|
||||
)
|
||||
|
||||
print("\n".join(commands))
|
||||
|
||||
authorized_keys.write_text("\n".join(commands) + "\n")
|
||||
|
||||
# remove repositories that do no longer exist
|
||||
repo_names = {repo.name for repo in repos}
|
||||
print(repo_names)
|
||||
for dir in homedir.iterdir():
|
||||
print(dir.name)
|
||||
if dir.is_dir() and not dir.name.startswith("."):
|
||||
if dir.name not in repo_names:
|
||||
print(f"removing unused repo '{dir}'")
|
||||
shutil.rmtree(dir)
|
||||
|
||||
# create the repositories
|
||||
for repo in repos:
|
||||
path = system_user["homedir"] / repo.name
|
||||
path.mkdir(mode=0o750, exist_ok=True, parents=True)
|
||||
shutil.chown(path, user=username)
|
||||
40
community_backup/webui/forms.py
Normal file
40
community_backup/webui/forms.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from .models import BorgRepository
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .models import Voucher
|
||||
|
||||
|
||||
class BorgRepositoryForm(forms.ModelForm):
|
||||
class Meta:
|
||||
fields = ["name", "key", "user"]
|
||||
model = BorgRepository
|
||||
widgets = {"user": forms.HiddenInput()}
|
||||
|
||||
|
||||
class RegisterUserForm(UserCreationForm):
|
||||
email = forms.EmailField()
|
||||
voucher = forms.CharField(help_text="You registration voucher.")
|
||||
|
||||
def clean_voucher(self):
|
||||
obj = Voucher.objects.filter(code=self.cleaned_data["voucher"], used=False)
|
||||
|
||||
if not obj.exists():
|
||||
raise ValidationError(
|
||||
"Voucher code '%(code)s' is invalid",
|
||||
code="invalid",
|
||||
params={"code": self.cleaned_data["voucher"]},
|
||||
)
|
||||
|
||||
return obj.first()
|
||||
|
||||
def save(self, commit=True):
|
||||
voucher = self.cleaned_data["voucher"]
|
||||
|
||||
voucher.used = True
|
||||
voucher.save()
|
||||
super().save(commit)
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
fields = ("username", "email")
|
||||
0
community_backup/webui/management/__init__.py
Normal file
0
community_backup/webui/management/__init__.py
Normal file
0
community_backup/webui/management/commands/__init__.py
Normal file
0
community_backup/webui/management/commands/__init__.py
Normal file
17
community_backup/webui/management/commands/sync_users.py
Normal file
17
community_backup/webui/management/commands/sync_users.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.core.management.base import BaseCommand, CommandError
|
||||
from ...models import BorgRepository
|
||||
from ...deployments import sync_users
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Synchronized users on the host with the database."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
repos = BorgRepository.objects.all()
|
||||
|
||||
for repo in repos:
|
||||
self.stdout.write(f"{repo}")
|
||||
self.stdout.write(self.style.SUCCESS("This is a test"))
|
||||
|
||||
sync_users(dry_run=False)
|
||||
40
community_backup/webui/migrations/0001_initial.py
Normal file
40
community_backup/webui/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Generated by Django 6.0.3 on 2026-03-23 06:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BorgRepository",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100)),
|
||||
("key", models.TextField()),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 6.0.3 on 2026-03-28 06:56
|
||||
|
||||
import django.core.validators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("webui", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="borgrepository",
|
||||
name="key",
|
||||
field=models.TextField(
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
"(ssh\\-rsa|ecdsa\\-sha2\\-nistp256|ssh\\-ed25519) ([a-zA-Z0-9\\+/]+) (\\S*)",
|
||||
message="not a valid SSH public key.",
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="borgrepository",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("name", "user"), name="BorgRepository_name_user_unique"
|
||||
),
|
||||
),
|
||||
]
|
||||
29
community_backup/webui/migrations/0003_voucher.py
Normal file
29
community_backup/webui/migrations/0003_voucher.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 6.0.3 on 2026-03-28 18:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("webui", "0002_alter_borgrepository_key_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Voucher",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("used", models.BooleanField(default=False)),
|
||||
("code", models.CharField(max_length=100, unique=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
community_backup/webui/migrations/__init__.py
Normal file
0
community_backup/webui/migrations/__init__.py
Normal file
59
community_backup/webui/models.py
Normal file
59
community_backup/webui/models.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
|
||||
# Create your models here.
|
||||
class BorgRepository(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
r"[a-zA-Z0-9\-_]+", message="Only a-z, A-Z, 0-9, - and _ are allowed."
|
||||
)
|
||||
],
|
||||
)
|
||||
key = models.TextField(
|
||||
validators=[
|
||||
RegexValidator(
|
||||
r"(ssh\-rsa|ecdsa\-sha2\-nistp256|ssh\-ed25519) ([a-zA-Z0-9\+/]+) (\S*)",
|
||||
message="not a valid SSH public key.",
|
||||
),
|
||||
]
|
||||
)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("name", "user"), name="BorgRepository_name_user_unique"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"BorgRepository '{self.user.username}' - '{self.name}' ({self.pk})"
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
"""Returns the username of the linux user account for this user."""
|
||||
return f"u-{self.pk}"
|
||||
|
||||
def save(self):
|
||||
from .tasks import update_user
|
||||
|
||||
update_user.enqueue(user_pk=self.user.pk)
|
||||
super().save()
|
||||
|
||||
def delete(self):
|
||||
from .tasks import update_user
|
||||
|
||||
super().delete()
|
||||
update_user.enqueue(user_pk=self.user.pk)
|
||||
|
||||
|
||||
class Voucher(models.Model):
|
||||
used = models.BooleanField(default=False)
|
||||
code = models.CharField(max_length=100, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Voucher '{self.code}'"
|
||||
12
community_backup/webui/tasks.py
Normal file
12
community_backup/webui/tasks.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from django.tasks import task
|
||||
from .deployments import sync_repos, get_users
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
@task
|
||||
def update_user(user_pk):
|
||||
user = User.objects.get(pk=user_pk)
|
||||
username = f"u-{user.pk}"
|
||||
system_user = get_users()[username]
|
||||
|
||||
sync_repos(username=username, user=user, system_user=system_user)
|
||||
6
community_backup/webui/templates/about.md
Normal file
6
community_backup/webui/templates/about.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# About Community Backup
|
||||
|
||||
Community Backup is a service that provides backup space free of charge.
|
||||
|
||||
* This is
|
||||
* a list
|
||||
62
community_backup/webui/templates/base.html
Normal file
62
community_backup/webui/templates/base.html
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Community Backup</title>
|
||||
</head>
|
||||
<body style="display: flex; flex-direction: column; height: 100vh">
|
||||
<header class="bg-body-tertiary">
|
||||
<div class="container">
|
||||
<nav class="navbar navbar-expand-lg">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/" >Community Backup</a>
|
||||
<div class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a class="nav-link" href="{% url 'borg_list' %}">Borg Repositories</a>
|
||||
<div class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><form method=post action="{% url 'logout' %}?next={% url 'landing' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn dropdown-item">Logout</button>
|
||||
</form></li>
|
||||
<li><a class="dropdown-item" href="{% url 'password_change' %}">Change Password</a></li>
|
||||
{% if user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<a class="btn nav-item" type="button" href="{% url 'login' %}?next={% url 'landing' %}">Login</a>
|
||||
<a class="btn nav-item" type="button" href="{% url 'register' %}">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container" style="flex: 1 1 auto">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
<footer class="bg-body-tertiary">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{% url "about" %}">About</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">Imprint</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
community_backup/webui/templates/borg_edit.html
Normal file
7
community_backup/webui/templates/borg_edit.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<form action="{% url borg_edit %}">
|
||||
{{ form }}
|
||||
</form>
|
||||
{% endblock %}
|
||||
28
community_backup/webui/templates/borg_list.html
Normal file
28
community_backup/webui/templates/borg_list.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex">
|
||||
<div class="p-2 ms-auto">
|
||||
<a class="btn btn-primary" href="{% url 'borg_add' %}" role="button">Add Repository</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<th>Name</th>
|
||||
<th>Key</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for repo in object_list %}
|
||||
<tr>
|
||||
<td>{{ repo.name }}</td>
|
||||
<td>{{ repo.key }}</td>
|
||||
<td><a href="{% url 'borg_update' pk=repo.pk %}" class="btn btn-warning btn-sm">Edit</a>
|
||||
<a href="{% url 'borg_delete' pk=repo.pk %}" class="btn btn-danger btn-sm">Delete</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
7
community_backup/webui/templates/landing.html
Normal file
7
community_backup/webui/templates/landing.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<row class="text-center p-2">
|
||||
<h1>Welcome to Community Backup!</h1>
|
||||
</row>
|
||||
{% endblock %}
|
||||
1
community_backup/webui/templates/login.html
Normal file
1
community_backup/webui/templates/login.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
Hello at the login template
|
||||
7
community_backup/webui/templates/markdown.html
Normal file
7
community_backup/webui/templates/markdown.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<row class="p-2">
|
||||
{{ markdown_content|safe }}
|
||||
</row>
|
||||
{% endblock %}
|
||||
28
community_backup/webui/templates/register.html
Normal file
28
community_backup/webui/templates/register.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if form.errors %}
|
||||
{% for error in form.errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if next %}
|
||||
{% if user.is_authenticated %}
|
||||
<p>Your account doesn't have access to this page. To proceed,
|
||||
please login with an account that has access.</p>
|
||||
{% else %}
|
||||
<p>Please login to see this page.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'register' %}">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input class="btn btn-primary" type="submit" value="Register">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
26
community_backup/webui/templates/registration/login.html
Normal file
26
community_backup/webui/templates/registration/login.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if form.errors %}
|
||||
<p>Your username and password didn't match. Please try again.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if next %}
|
||||
{% if user.is_authenticated %}
|
||||
<p>Your account doesn't have access to this page. To proceed,
|
||||
please login with an account that has access.</p>
|
||||
{% else %}
|
||||
<p>Please login to see this page.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input class="btn btn-primary" type="submit" value="login">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">{% csrf_token %}
|
||||
<p>Are you sure you want to delete "{{ object.name }}"?</p>
|
||||
{{ form }}
|
||||
<input class="btn btn-danger" type="submit" value="Confirm">
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
{% if form.initial %}
|
||||
<input class="btn btn-primary" type="submit" value="Save">
|
||||
{% else %}
|
||||
<input class="btn btn-primary" type="submit" value="Add">
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
12
community_backup/webui/templatetags.py
Normal file
12
community_backup/webui/templatetags.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from django import template
|
||||
from django.template.defaultfilters import stringfilter
|
||||
|
||||
import markdown2 as md
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter()
|
||||
@stringfilter
|
||||
def markdown(value):
|
||||
return md.markdown(value, extensions=["markdown.extensions.fenced_code"])
|
||||
3
community_backup/webui/tests.py
Normal file
3
community_backup/webui/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
21
community_backup/webui/urls.py
Normal file
21
community_backup/webui/urls.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.LandingView.as_view(), name="landing"),
|
||||
path("borg/", views.BorgView.as_view(), name="borg_list"),
|
||||
path("borg/add/", views.BorgRepositoryCreateView.as_view(), name="borg_add"),
|
||||
path(
|
||||
"borg/update/<int:pk>/",
|
||||
views.BorgRepositoryUpdateView.as_view(),
|
||||
name="borg_update",
|
||||
),
|
||||
path(
|
||||
"borg/delete/<int:pk>/",
|
||||
views.BorgRepositoryDeleteView.as_view(),
|
||||
name="borg_delete",
|
||||
),
|
||||
path("register/", views.RegisterUserView.as_view(), name="register"),
|
||||
path("about/", views.AboutView.as_view(), name="about"),
|
||||
]
|
||||
93
community_backup/webui/views.py
Normal file
93
community_backup/webui/views.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.views.generic import ListView, FormView
|
||||
from django.views.generic.edit import CreateView, UpdateView, DeleteView
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.forms import BaseUserCreationForm
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
|
||||
|
||||
from django.template.loader import get_template
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
import markdown2
|
||||
|
||||
from .models import BorgRepository, Voucher
|
||||
from .forms import BorgRepositoryForm, RegisterUserForm
|
||||
|
||||
|
||||
class UserOwnsRepositoryMixin(UserPassesTestMixin):
|
||||
def test_func(self):
|
||||
repo = get_object_or_404(BorgRepository, pk=self.kwargs["pk"])
|
||||
return repo.user == self.request.user
|
||||
|
||||
|
||||
class LoginView(TemplateView):
|
||||
template_name = "login.html"
|
||||
|
||||
|
||||
class LandingView(TemplateView):
|
||||
template_name = "landing.html"
|
||||
|
||||
|
||||
class MarkdownView(TemplateView):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
source = get_template(self.md).template.source
|
||||
context["markdown_content"] = markdown2.markdown(source)
|
||||
return context
|
||||
|
||||
|
||||
class AboutView(MarkdownView):
|
||||
template_name = "markdown.html"
|
||||
md = "about.md"
|
||||
|
||||
|
||||
class BorgView(LoginRequiredMixin, ListView):
|
||||
template_name = "borg_list.html"
|
||||
content_object_name = "borg_repositories"
|
||||
|
||||
def get_queryset(self):
|
||||
data = BorgRepository.objects.filter(user=self.request.user)
|
||||
return data
|
||||
|
||||
|
||||
class RegisterUserView(FormView):
|
||||
template_name = "register.html"
|
||||
form_class = RegisterUserForm
|
||||
success_url = "/"
|
||||
|
||||
def form_valid(self, form):
|
||||
form.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class BorgRepositoryCreateView(LoginRequiredMixin, CreateView):
|
||||
model = BorgRepository
|
||||
success_url = "/borg/"
|
||||
form_class = BorgRepositoryForm
|
||||
|
||||
def get_initial(self):
|
||||
return {"user": self.request.user}
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.user = self.request.user
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class BorgRepositoryDeleteView(LoginRequiredMixin, UserOwnsRepositoryMixin, DeleteView):
|
||||
model = BorgRepository
|
||||
success_url = "/borg/"
|
||||
|
||||
|
||||
class BorgRepositoryUpdateView(LoginRequiredMixin, UserOwnsRepositoryMixin, UpdateView):
|
||||
model = BorgRepository
|
||||
success_url = "/borg/"
|
||||
form_class = BorgRepositoryForm
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.user = self.request.user
|
||||
return super().form_valid(form)
|
||||
12
pyproject.toml
Normal file
12
pyproject.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[project]
|
||||
name = "community_backup"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"crispy-bootstrap5>=2026.3",
|
||||
"django>=6.0.3",
|
||||
"django-crispy-forms>=2.6",
|
||||
"markdown2>=2.5.5",
|
||||
]
|
||||
97
uv.lock
generated
Normal file
97
uv.lock
generated
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "community-backup"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "crispy-bootstrap5" },
|
||||
{ name = "django" },
|
||||
{ name = "django-crispy-forms" },
|
||||
{ name = "markdown2" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "crispy-bootstrap5", specifier = ">=2026.3" },
|
||||
{ name = "django", specifier = ">=6.0.3" },
|
||||
{ name = "django-crispy-forms", specifier = ">=2.6" },
|
||||
{ name = "markdown2", specifier = ">=2.5.5" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crispy-bootstrap5"
|
||||
version = "2026.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-crispy-forms" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/e8/05e1170f6b8fbfe4098392bdca813c2094659853a438919234d4663009b1/crispy_bootstrap5-2026.3.tar.gz", hash = "sha256:e7f5adb36acfbb456444c46e82c436931c796c539e9c620be4fa9dc9c9d6679c", size = 23452, upload-time = "2026-03-01T10:08:00.459Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/2d/93f78072f203aa28d961add6a130b929b47f7aa3fef6905898b4bb9a637d/crispy_bootstrap5-2026.3-py3-none-any.whl", hash = "sha256:e0fff85c0503e9aed610a0ee31368e2191d340657f813669491c288c1c2e2dfa", size = 24770, upload-time = "2026-03-01T10:07:59.058Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-crispy-forms"
|
||||
version = "2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/42/c2cfb672493730b963ef377b103e29871c56348a215d0ae8cf362fe8ab1e/django_crispy_forms-2.6.tar.gz", hash = "sha256:4921a1087c6cd4f9fa3c139654c1de1c1c385f8bd6729aaee530bc0121ab4b93", size = 1097838, upload-time = "2026-03-01T09:03:37.138Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e3/4c5915a732d6ab54da8871400852b67529518eedfb6b78ecf10bbccfcabb/django_crispy_forms-2.6-py3-none-any.whl", hash = "sha256:8ee0ae28b6b0ac41ff48a65944480c049fe8d1b0047086874fd7efabf4ec1374", size = 31479, upload-time = "2026-03-01T09:03:36.048Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown2"
|
||||
version = "2.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue