Added very basic storage model and WIP API

This commit is contained in:
Jordi Loyzaga 2024-02-12 04:00:59 -06:00
parent 3f89efcb36
commit ead978715c
33 changed files with 535 additions and 56 deletions

View File

@ -1,4 +0,0 @@
[flake8]
max-line-length=120
ignore = E126,E128,W504
exclude = */migrations/[0-9]*, .pyscripts/*,pyenv*,.pyenv*, .git, .venv

5
.gitignore vendored
View File

@ -47,6 +47,7 @@ coverage.xml
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/ cover/
.ruff_cache/
# Translations # Translations
*.mo *.mo
@ -89,4 +90,6 @@ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
.vscode .vscode
lockbox/media

View File

@ -1,11 +1,8 @@
lint: lint:
@flake8 --config=.flake8 /dev/null $(shell git diff --name-only HEAD | grep '\.py$$' ) @ruff check $(shell git diff --diff-filter=ACM --name-only HEAD | grep '\.py$$' )
stampreqs: stampreqs:
poetry export --without-hashes --format=requirements.txt > requirements.txt poetry export --without-hashes --format=requirements.txt > requirements.txt
test: test:
pytest --cov=. --cov-report term-missing pytest --cov=. --cov-report term-missing
testcap:
pytest --cov=. --cov-report term-missing -s

View File

@ -0,0 +1 @@
# Lockbox File Sharing Service

View File

@ -1,3 +1,10 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from common.models import Configuration
class LockboxModelAdmin(admin.ModelAdmin):
readonly_fields = Configuration.readonly_fields
admin.site.register(Configuration, LockboxModelAdmin)

View File

@ -1,4 +1,6 @@
from django.apps import AppConfig from django.apps import (
AppConfig,
)
class CommonConfig(AppConfig): class CommonConfig(AppConfig):

View File

@ -0,0 +1,31 @@
class UPLOAD_STATUS_TYPES:
UPLOADING = "uploading"
COMPLETED = "completed"
ABANDONED = "abandoned"
# Config
CONFIG_KEYS = {
"EXPIRATION_DELTA_MINUTES": {
"description": "Date created + this delta at which file expires",
"verbose_name": "File expiration delta (minutes)",
"native_type": int,
"default": 120,
},
"ABANDONED_DELTA_MINUTES": {
"description": "Date created + this delta at which a file is marked as abandoned",
"verbose_name": "Uncompleted file abandoned max age",
"native_type": int,
"default": 20,
},
"ABANDONED_EXPIRED_SCAN_INTERVAL": {
"description": "Scan and scrub abandoned or expired uploads",
"verbose_name": "Scan interval for abandoned/expired files",
"native_type": int,
"default": 20,
},
}

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.10 on 2024-02-12 08:39
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Configuration',
fields=[
('lid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='lockbox ID')),
('date_created', models.DateTimeField(blank=True, help_text='date at which this object was created', verbose_name='date created')),
('date_updated', models.DateTimeField(blank=True, help_text='date at which this object was last updated', verbose_name='date updated')),
('key', models.CharField(choices=[('EXPIRATION_DELTA_MINUTES', 'Date created + this delta at which file expires'), ('ABANDONED_DELTA_MINUTES', 'Date created + this delta at which a file is marked as abandoned'), ('ABANDONED_EXPIRED_SCAN_INTERVAL', 'Scan and scrub abandoned or expired uploads')], help_text='internal configuration key name', max_length=50)),
('value', models.CharField(help_text='actual DB config value', max_length=1024)),
],
options={
'abstract': False,
},
),
]

View File

@ -1,24 +1,41 @@
from django.db import models from typing import Any, NamedTuple
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from uuid import uuid4 from uuid import uuid4
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from common.constants import CONFIG_KEYS
from common.utils import cast_to_native_type
class LockboxBase(models.Model): # pragma: no cover class LockboxBase(models.Model): # pragma: no cover
lid = models.UUIDField( lid = models.UUIDField(
primary_key=True, primary_key=True,
default=uuid4 default=uuid4,
verbose_name=_("lockbox ID"),
) )
date_created = models.DateTimeField( date_created = models.DateTimeField(
verbose_name=_("date created") verbose_name=_("date created"),
help_text=_("date at which this object was created"),
null=False,
blank=True,
) )
date_updated = models.DateTimeField( date_updated = models.DateTimeField(
verbose_name=_("date updated"), verbose_name=_("date updated"),
null=True help_text=_("date at which this object was last updated"),
null=False,
blank=True,
) )
readonly_fields = [
"date_created",
"date_updated",
"lid",
]
class Meta: class Meta:
abstract = True abstract = True
@ -29,6 +46,47 @@ class LockboxBase(models.Model): # pragma: no cover
if not self.date_created: if not self.date_created:
self.date_created = now self.date_created = now
self.date_update = now self.date_updated = now
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self):
return f"{self.__class__.__name__} Object {self.lid}"
class Config(NamedTuple):
key: str
value: Any
native_type: type # change to type
description: str
source: str
default: Any
class Configuration(LockboxBase):
CONFIG_KEY_CHOICES = (
(key, value["description"]) for key, value in CONFIG_KEYS.items()
)
key = models.CharField(
choices=CONFIG_KEY_CHOICES,
max_length=50,
null=False,
blank=False,
help_text=_("internal configuration key name"),
)
value = models.CharField(
max_length=1024,
null=False,
blank=False,
help_text=_("actual DB config value"),
)
readonly_fields = LockboxBase.readonly_fields
def save(self, *args, **kwargs):
native_type = CONFIG_KEYS[self.key]["native_type"]
cast_to_native_type(self.key, self.value, native_type)
return super().save(*args, **kwargs)

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

40
lockbox/common/utils.py Normal file
View File

@ -0,0 +1,40 @@
from os import getenv
from unicodedata import normalize
from common.constants import CONFIG_KEYS
def normalize_string(string, form="NFKC"):
return normalize(form, string)
def cast_to_native_type(key, value, native_type):
try:
return native_type(value)
except ValueError as e:
message = (
f"Received unexpected value type for configuration key {key}\nValue: {value}\nExpected type : {native_type}"
)
raise ValueError(message) from e
def get_config(key):
from common.models import Config, Configuration
config = Config(CONFIG_KEYS[key])
obj = Configuration.objects.filter(key=key).first()
if obj:
config.value = cast_to_native_type(key, obj.value, config.native_type)
config.source = "db"
return config
value = getenv(key)
if value:
config.value = cast_to_native_type(key, value, config.native_type)
config.source = "env_variable"
return config
config.value = config.default
config.source = "default"
return config

View File

@ -1,3 +1,3 @@
from django.shortcuts import render # from django.shortcuts import render
# Create your views here. # Create your views here.

View File

@ -1,15 +1,13 @@
""" """Lockbox File Sharing"""
Lockbox File Sharing
"""
from pathlib import Path
import os import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv("LOCKBOX_SECRET_KEY") SECRET_KEY = os.getenv("LOCKBOX_SECRET_KEY")
DEBUG = os.getenv("LOCKBOX_DEBUG", False) DEBUG = os.getenv("LOCKBOX_DEBUG")
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
@ -23,12 +21,13 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
# Vendors # Vendors
# Apps # Apps
'common', 'common',
'user', 'user',
'storage',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -67,7 +66,7 @@ DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3',
} },
} }
@ -101,6 +100,10 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
STATIC_URL = 'static/' STATIC_URL = 'static/'
# Storage
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "files/"
# Default primary key field type # Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'user.LockboxUser' AUTH_USER_MODEL = 'user.LockboxUser'

View File

@ -14,9 +14,13 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
] ]
urlpatterns.extend(static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT))

View File

9
lockbox/storage/admin.py Normal file
View File

@ -0,0 +1,9 @@
from django.contrib import admin
from storage.models import File
class FileAdmin(admin.ModelAdmin):
readonly_fields = File.readonly_fields
admin.site.register(File, FileAdmin)

6
lockbox/storage/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class StorageConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'storage'

View File

@ -0,0 +1,41 @@
# Generated by Django 4.2.10 on 2024-02-12 09:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import storage.models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('lid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='lockbox ID')),
('date_created', models.DateTimeField(blank=True, help_text='date at which this object was created', verbose_name='date created')),
('date_updated', models.DateTimeField(blank=True, help_text='date at which this object was last updated', verbose_name='date updated')),
('name', models.CharField(help_text='display name of this file', max_length=255, verbose_name='name')),
('extension', models.CharField(blank=True, help_text='reported filesystem extension (not mime type)', max_length=128, null=True, verbose_name='extension')),
('file', models.FileField(help_text='actual file', upload_to=storage.models.get_upload_path, verbose_name='file')),
('position', models.PositiveBigIntegerField(default=0, help_text='current position of uploaded bytes', verbose_name='position')),
('status', models.CharField(choices=[('uploading', 'uploading'), ('completed', 'completed'), ('abandoned', 'abandoned')], default='uploading', help_text='upload status for file', max_length=9, verbose_name='status')),
('date_completed', models.DateTimeField(blank=True, help_text="datetime at which this file's upload was completed", null=True, verbose_name='completed on')),
('expires', models.BooleanField(default=False, help_text="will be scrubbed on 'date_expires'", verbose_name='expires')),
('delete_on_expiration', models.BooleanField(default=False, help_text='will be deleted if expired and expires is true', verbose_name='delete on expiration')),
('size_on_disk', models.PositiveBigIntegerField(blank=True, help_text='total size on disk for this file', null=True, verbose_name='size on disk (bytes)')),
('owner', models.ForeignKey(blank=True, help_text='owner of this file', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files_owned', to=settings.AUTH_USER_MODEL, verbose_name='owner')),
],
options={
'verbose_name': 'file',
'verbose_name_plural': 'files',
},
),
]

View File

199
lockbox/storage/models.py Normal file
View File

@ -0,0 +1,199 @@
import hashlib
from pathlib import Path
from common.constants import CONFIG_KEYS, UPLOAD_STATUS_TYPES
from common.models import LockboxBase
from common.utils import get_config, normalize_string
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
def get_upload_path(instance, filename):
filename = normalize_string(filename) + ".part"
file_subdir = settings.MEDIA_ROOT / str(instance.lid)
if not Path.exists(file_subdir):
Path.mkdir(file_subdir)
return Path(str(instance.lid)) / Path(filename)
class File(LockboxBase):
name = models.CharField(
max_length=255,
null=False,
blank=False,
verbose_name = _("name"),
help_text=_("display name of this file"),
)
extension = models.CharField(
max_length=128,
blank=True,
null=True,
verbose_name=_("extension"),
help_text=_("reported filesystem extension (not mime type)"),
)
file = models.FileField(
upload_to=get_upload_path,
null=False,
blank=False,
verbose_name=_("file"),
help_text=_("actual file"),
)
position = models.PositiveBigIntegerField(
default=0,
blank=False,
null=False,
verbose_name=_("position"),
help_text=_("current position of uploaded bytes"),
)
UPLOAD_CHOICES = (
(UPLOAD_STATUS_TYPES.UPLOADING, _(UPLOAD_STATUS_TYPES.UPLOADING)),
(UPLOAD_STATUS_TYPES.COMPLETED, _(UPLOAD_STATUS_TYPES.COMPLETED)),
(UPLOAD_STATUS_TYPES.ABANDONED, _(UPLOAD_STATUS_TYPES.ABANDONED)),
)
status = models.CharField(
max_length=9,
choices=UPLOAD_CHOICES,
default=UPLOAD_STATUS_TYPES.UPLOADING,
blank=False,
null=False,
verbose_name=_("status"),
help_text=_("upload status for file"),
)
date_completed = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("completed on"),
help_text=_("datetime at which this file's upload was completed"),
)
owner = models.ForeignKey(
"user.LockboxUser",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="files_owned",
verbose_name=_("owner"),
help_text=_("owner of this file"),
)
expires = models.BooleanField(
null=False,
blank=False,
default=False,
verbose_name = _("expires"),
help_text=_("will be scrubbed on 'date_expires'"),
)
delete_on_expiration = models.BooleanField(
null=False,
blank=False,
default=False,
verbose_name=_("delete on expiration"),
help_text=_("will be deleted if expired and expires is true"),
)
size_on_disk = models.PositiveBigIntegerField(
null=True,
blank=True,
verbose_name=_("size on disk (bytes)"),
help_text=_("total size on disk for this file"),
)
readonly_fields = [
"extension",
"position",
"status",
"date_completed",
"size_on_disk",
*LockboxBase.readonly_fields,
]
def __str__(self):
return self.name
class Meta:
verbose_name = _("file")
verbose_name_plural = _("files")
@property
def md5(self):
if getattr(self, "_md5", None) is None:
md5 = hashlib.md5() # noqa:S324 this is needed due to how chunked files work.
for chunk in self.file.chunks():
md5.update(chunk)
self._md5 = md5.hexdigest()
return self._md5
@property
def date_expires(self):
return self.date_created + get_config(CONFIG_KEYS["EXPIRATION_DELTA_MINUTES"])
@property
def abandoned(self):
return self.date_created + get_config(CONFIG_KEYS["ABANDONED_DELTA_MINUTES"])
@property
def expired(self):
return self.date_expires <= timezone.now()
def delete(self, *args, delete_file=True, **kwargs):
if self.file:
storage, path = self.file.storage, self.file.path
super().delete(*args, **kwargs)
if self.file and delete_file:
storage.delete(path)
def get_file_handler_bytes(self):
self.file.close()
self.file.open(mode="rb")
return UploadedFile(file=self.file, name=self.filename, size=self.offset)
def append_chunk(self, chunk, chunk_size=None, save=None):
# file handler might be open for some bizzare reason
self.file.close()
with Path.open(self.file.path, mode="ab") as fh:
fh.write(
chunk.read(),
)
if chunk_size is not None:
# file is chunked
self.postition += chunk_size
elif hasattr(chunk, "size"):
self.postition += chunk.size
else:
# file is one shot (small file)
self.postition = self.file.size
self._md5 = None
if save:
self.save()
self.file.close()
# class FileShare(LockboxBase):
# file = models.ForeignKey(
# "storage.File",
# null=False,
# blank=False,
# on_delete=models.CASCADE,
# related_name="shares",
# )
# def __str__(self):
# return self.file.name
# class Meta:
# verbose_name = _("share")
# verbose_name_plural = _("shares")

View File

@ -0,0 +1,2 @@
from django.core.files.storage import FileSystemStorage

View File

3
lockbox/storage/views.py Normal file
View File

@ -0,0 +1,3 @@
# from django.shortcuts import render
# Create your views here.

View File

@ -1,3 +1,9 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from user.models import LockboxUser
class LockboxUserAdmin(admin.ModelAdmin):
readonly_fields = LockboxUser.readonly_fields
admin.site.register(LockboxUser, LockboxUserAdmin)

View File

@ -1,6 +1,6 @@
from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.base_user import BaseUserManager
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db import models
class LockboxUserManager(BaseUserManager): class LockboxUserManager(BaseUserManager):
def create_user(self, username, password, **extra_fields): def create_user(self, username, password, **extra_fields):

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.10 on 2024-02-10 10:51 # Generated by Django 4.2.10 on 2024-02-12 08:39
import django.contrib.auth.validators import django.contrib.auth.validators
from django.db import migrations, models from django.db import migrations, models
@ -25,10 +25,10 @@ class Migration(migrations.Migration):
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('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')), ('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')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('lid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('lid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='lockbox ID')),
('date_created', models.DateTimeField(verbose_name='date created')), ('date_created', models.DateTimeField(blank=True, help_text='date at which this object was created', verbose_name='date created')),
('date_updated', models.DateTimeField(null=True, verbose_name='date updated')), ('date_updated', models.DateTimeField(blank=True, help_text='date at which this object was last updated', verbose_name='date updated')),
('alias', models.SlugField(blank=True, help_text='An alias or nickname to remember who this is', max_length=32, null=True, unique=True, verbose_name='name')), ('alias', models.SlugField(blank=True, help_text='an alias or nickname to remember who this is', max_length=32, null=True, unique=True, verbose_name='name')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
], ],

View File

@ -1,10 +1,8 @@
from common.models import LockboxBase
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from uuid import uuid4
from common.models import LockboxBase
from user.managers import LockboxUserManager from user.managers import LockboxUserManager
@ -15,8 +13,7 @@ class LockboxUser(AbstractUser, LockboxBase):
unique=True, unique=True,
null=True, null=True,
blank=True, blank=True,
validators=[], help_text=_("an alias or nickname to remember who this is"),
help_text=_("An alias or nickname to remember who this is")
) )
# Void this stuff. # Void this stuff.

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -1,13 +1,10 @@
import pytest import pytest
from django.core.exceptions import ValidationError
from user.models import LockboxUser
@pytest.mark.django_db() @pytest.mark.django_db()
class TestUser: class TestUser:
""" """
Test util default creation functions are working. Test user related functions are working correctly.
""" """
def test_stub(self):
assert True

View File

@ -1,3 +1,3 @@
from django.shortcuts import render # from django.shortcuts import render
# Create your views here. # Create your views here.

28
poetry.lock generated
View File

@ -202,6 +202,32 @@ pytest = ">=7.0.0"
docs = ["sphinx", "sphinx-rtd-theme"] docs = ["sphinx", "sphinx-rtd-theme"]
testing = ["Django", "django-configurations (>=2.0)"] testing = ["Django", "django-configurations (>=2.0)"]
[[package]]
name = "ruff"
version = "0.2.1"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"},
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"},
{file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"},
{file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"},
{file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"},
{file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"},
]
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.4.4" version = "0.4.4"
@ -232,4 +258,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "3.12" python-versions = "3.12"
content-hash = "824df996658026864e02f217b5df60d87723b3f06f3e8c5c1d265abf7851b978" content-hash = "1a0f3ef0953e311eaf5daaff9b7980e0211fb0e5195344a28881b731bca3b7eb"

View File

@ -14,6 +14,7 @@ django = "~4.2.0"
pytest = "^8.0.0" pytest = "^8.0.0"
pytest-django = "^4.8.0" pytest-django = "^4.8.0"
pytest-cov = "^4.1.0" pytest-cov = "^4.1.0"
ruff = "^0.2.1"
[tool.pytest.ini_options] [tool.pytest.ini_options]
@ -46,6 +47,34 @@ omit = [
] ]
[tool.ruff]
exclude = [
"*/migrations/[0-9]*",
".pyscripts/*",
"pyenv*",
".pyenv*",
".git",
".venv",
]
line-length = 120
target-version = "py312"
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"ANN",
"D",
"DJ001",
"DJ012",
"ERA001",
"N801",
"Q000",
"RUF012",
"TRY",
"S101",
"SLF001",
]
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]