school-library-api 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- school_library_api-0.1.0/PKG-INFO +17 -0
- school_library_api-0.1.0/README.md +0 -0
- school_library_api-0.1.0/backend/Dockerfile +22 -0
- school_library_api-0.1.0/backend/core/__init__.py +0 -0
- school_library_api-0.1.0/backend/core/asgi.py +16 -0
- school_library_api-0.1.0/backend/core/settings.py +143 -0
- school_library_api-0.1.0/backend/core/urls.py +23 -0
- school_library_api-0.1.0/backend/core/wsgi.py +16 -0
- school_library_api-0.1.0/backend/db.sqlite3 +0 -0
- school_library_api-0.1.0/backend/docker-compose.yml +33 -0
- school_library_api-0.1.0/backend/libraly/__init__.py +0 -0
- school_library_api-0.1.0/backend/libraly/admin.py +46 -0
- school_library_api-0.1.0/backend/libraly/apps.py +5 -0
- school_library_api-0.1.0/backend/libraly/migrations/0001_initial.py +43 -0
- school_library_api-0.1.0/backend/libraly/migrations/0002_initial.py +41 -0
- school_library_api-0.1.0/backend/libraly/migrations/__init__.py +0 -0
- school_library_api-0.1.0/backend/libraly/models.py +61 -0
- school_library_api-0.1.0/backend/libraly/permissions.py +31 -0
- school_library_api-0.1.0/backend/libraly/serializers.py +53 -0
- school_library_api-0.1.0/backend/libraly/tests/conftest.py +59 -0
- school_library_api-0.1.0/backend/libraly/tests/test_models.py +46 -0
- school_library_api-0.1.0/backend/libraly/tests.py +3 -0
- school_library_api-0.1.0/backend/libraly/urls.py +16 -0
- school_library_api-0.1.0/backend/libraly/views.py +178 -0
- school_library_api-0.1.0/backend/manage.py +22 -0
- school_library_api-0.1.0/backend/users/__init__.py +0 -0
- school_library_api-0.1.0/backend/users/admin.py +3 -0
- school_library_api-0.1.0/backend/users/apps.py +5 -0
- school_library_api-0.1.0/backend/users/authentication.py +6 -0
- school_library_api-0.1.0/backend/users/migrations/0001_initial.py +34 -0
- school_library_api-0.1.0/backend/users/migrations/__init__.py +0 -0
- school_library_api-0.1.0/backend/users/models.py +43 -0
- school_library_api-0.1.0/backend/users/serializers.py +27 -0
- school_library_api-0.1.0/backend/users/tests.py +3 -0
- school_library_api-0.1.0/backend/users/views.py +57 -0
- school_library_api-0.1.0/pyproject.toml +34 -0
- school_library_api-0.1.0/pytest.ini +3 -0
- school_library_api-0.1.0/requirements.txt +0 -0
- school_library_api-0.1.0//320/237/320/276/320/264/320/263/320/276/321/202/320/276/320/262/320/272/320/260.md +304 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: school-library-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: REST API школьной библиотеки учебников (Django + DRF)
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourname/school-library-api
|
|
6
|
+
Author-email: Your Name <you@example.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Framework :: Django :: 5.0
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: django-filter>=24.0
|
|
15
|
+
Requires-Dist: django>=5.0
|
|
16
|
+
Requires-Dist: djangorestframework>=3.15
|
|
17
|
+
Requires-Dist: psycopg[binary]>=3.1
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
FROM python:3.11-slim
|
|
3
|
+
|
|
4
|
+
ENV PYTHONDONTWRITEBYTECODE=1
|
|
5
|
+
ENV PYTHONUNBUFFERED=1
|
|
6
|
+
|
|
7
|
+
WORKDIR /app
|
|
8
|
+
|
|
9
|
+
RUN apt-get update && apt-get install -y \
|
|
10
|
+
build-essential \
|
|
11
|
+
libpq-dev \
|
|
12
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
13
|
+
|
|
14
|
+
COPY requirements.txt /app/
|
|
15
|
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
|
16
|
+
|
|
17
|
+
COPY ./backend /app/backend
|
|
18
|
+
WORKDIR /app/backend
|
|
19
|
+
|
|
20
|
+
ENV DJANGO_SETTINGS_MODULE=core.settings
|
|
21
|
+
|
|
22
|
+
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASGI config for core project.
|
|
3
|
+
|
|
4
|
+
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from django.core.asgi import get_asgi_application
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
|
15
|
+
|
|
16
|
+
application = get_asgi_application()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django settings for core project.
|
|
3
|
+
|
|
4
|
+
Generated by 'django-admin startproject' using Django 6.0.6.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/6.0/topics/settings/
|
|
8
|
+
|
|
9
|
+
For the full list of settings and their values, see
|
|
10
|
+
https://docs.djangoproject.com/en/6.0/ref/settings/
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import os
|
|
15
|
+
import dotenv
|
|
16
|
+
|
|
17
|
+
dotenv.load_dotenv()
|
|
18
|
+
|
|
19
|
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
20
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Quick-start development settings - unsuitable for production
|
|
24
|
+
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
|
25
|
+
|
|
26
|
+
# SECURITY WARNING: keep the secret key used in production secret!
|
|
27
|
+
SECRET_KEY = 'django-insecure-=ac++6&wy5gthazc(e(!^5ss3=wuwjb@hn**uj&&y(8$&4gns1'
|
|
28
|
+
|
|
29
|
+
# SECURITY WARNING: don't run with debug turned on in production!
|
|
30
|
+
DEBUG = True
|
|
31
|
+
|
|
32
|
+
ALLOWED_HOSTS = []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Application definition
|
|
36
|
+
|
|
37
|
+
INSTALLED_APPS = [
|
|
38
|
+
'django.contrib.admin',
|
|
39
|
+
'django.contrib.auth',
|
|
40
|
+
'django.contrib.contenttypes',
|
|
41
|
+
'django.contrib.sessions',
|
|
42
|
+
'django.contrib.messages',
|
|
43
|
+
'django.contrib.staticfiles',
|
|
44
|
+
'rest_framework',
|
|
45
|
+
'rest_framework.authtoken',
|
|
46
|
+
'school_library.users',
|
|
47
|
+
'school_library.libraly'
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
AUTH_USER_MODEL = 'users.User'
|
|
51
|
+
|
|
52
|
+
REST_FRAMEWORK = {
|
|
53
|
+
"DEFAULT_AUTHENTICATION_CLASSES":[
|
|
54
|
+
'users.authentication.BearerToken'
|
|
55
|
+
],
|
|
56
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
57
|
+
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
|
58
|
+
],
|
|
59
|
+
"DEFAULT_FILTER_BACKENDS": [
|
|
60
|
+
"django_filters.rest_framework.DjangoFilterBackend",
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
MIDDLEWARE = [
|
|
65
|
+
'django.middleware.security.SecurityMiddleware',
|
|
66
|
+
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
67
|
+
'django.middleware.common.CommonMiddleware',
|
|
68
|
+
'django.middleware.csrf.CsrfViewMiddleware',
|
|
69
|
+
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
70
|
+
'django.contrib.messages.middleware.MessageMiddleware',
|
|
71
|
+
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
ROOT_URLCONF = 'core.urls'
|
|
75
|
+
|
|
76
|
+
TEMPLATES = [
|
|
77
|
+
{
|
|
78
|
+
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
79
|
+
'DIRS': [],
|
|
80
|
+
'APP_DIRS': True,
|
|
81
|
+
'OPTIONS': {
|
|
82
|
+
'context_processors': [
|
|
83
|
+
'django.template.context_processors.request',
|
|
84
|
+
'django.contrib.auth.context_processors.auth',
|
|
85
|
+
'django.contrib.messages.context_processors.messages',
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
WSGI_APPLICATION = 'core.wsgi.application'
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Database
|
|
95
|
+
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
|
96
|
+
|
|
97
|
+
DATABASES = {
|
|
98
|
+
"default": {
|
|
99
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
100
|
+
"NAME": os.environ.get("POSTGRES_DB", "school_library"),
|
|
101
|
+
"USER": os.environ.get("POSTGRES_USER", "library_user"),
|
|
102
|
+
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "library_password"),
|
|
103
|
+
"HOST": os.environ.get("POSTGRES_HOST", "db"),
|
|
104
|
+
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Password validation
|
|
110
|
+
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
|
111
|
+
|
|
112
|
+
AUTH_PASSWORD_VALIDATORS = [
|
|
113
|
+
{
|
|
114
|
+
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Internationalization
|
|
129
|
+
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
|
130
|
+
|
|
131
|
+
LANGUAGE_CODE = 'en-us'
|
|
132
|
+
|
|
133
|
+
TIME_ZONE = 'UTC'
|
|
134
|
+
|
|
135
|
+
USE_I18N = True
|
|
136
|
+
|
|
137
|
+
USE_TZ = True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Static files (CSS, JavaScript, Images)
|
|
141
|
+
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
|
142
|
+
|
|
143
|
+
STATIC_URL = 'static/'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL configuration for core project.
|
|
3
|
+
|
|
4
|
+
The `urlpatterns` list routes URLs to views. For more information please see:
|
|
5
|
+
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
|
6
|
+
Examples:
|
|
7
|
+
Function views
|
|
8
|
+
1. Add an import: from my_app import views
|
|
9
|
+
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
10
|
+
Class-based views
|
|
11
|
+
1. Add an import: from other_app.views import Home
|
|
12
|
+
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
13
|
+
Including another URLconf
|
|
14
|
+
1. Import the include() function: from django.urls import include, path
|
|
15
|
+
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
16
|
+
"""
|
|
17
|
+
from django.contrib import admin
|
|
18
|
+
from django.urls import path, include
|
|
19
|
+
|
|
20
|
+
urlpatterns = [
|
|
21
|
+
path('admin/', admin.site.urls),
|
|
22
|
+
path('api/', include('libraly.urls'))
|
|
23
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WSGI config for core project.
|
|
3
|
+
|
|
4
|
+
It exposes the WSGI callable as a module-level variable named ``application``.
|
|
5
|
+
|
|
6
|
+
For more information on this file, see
|
|
7
|
+
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from django.core.wsgi import get_wsgi_application
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
|
15
|
+
|
|
16
|
+
application = get_wsgi_application()
|
|
Binary file
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
version: "3.9"
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
web:
|
|
5
|
+
build: .
|
|
6
|
+
container_name: library_web
|
|
7
|
+
command: python manage.py runserver 0.0.0.0:8000
|
|
8
|
+
volumes:
|
|
9
|
+
- ./backend:/app/backend
|
|
10
|
+
env_file:
|
|
11
|
+
- .env
|
|
12
|
+
depends_on:
|
|
13
|
+
- db
|
|
14
|
+
ports:
|
|
15
|
+
- "8000:8000"
|
|
16
|
+
|
|
17
|
+
db:
|
|
18
|
+
image: postgres:14
|
|
19
|
+
container_name: library_db
|
|
20
|
+
restart: always
|
|
21
|
+
env_file:
|
|
22
|
+
- .env
|
|
23
|
+
environment:
|
|
24
|
+
POSTGRES_DB: ${POSTGRES_DB}
|
|
25
|
+
POSTGRES_USER: ${POSTGRES_USER}
|
|
26
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
27
|
+
volumes:
|
|
28
|
+
- postgres_data:/var/lib/postgresql/data
|
|
29
|
+
ports:
|
|
30
|
+
- "5432:5432"
|
|
31
|
+
|
|
32
|
+
volumes:
|
|
33
|
+
postgres_data:
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from .models import Subject, Textbook, Borrow
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BorrowInline(admin.TabularInline):
|
|
6
|
+
model = Borrow
|
|
7
|
+
extra = 0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@admin.register(Subject)
|
|
11
|
+
class SubjectAdmin(admin.ModelAdmin):
|
|
12
|
+
list_display = ("id", "name", "created_at")
|
|
13
|
+
search_fields = ("name",)
|
|
14
|
+
list_filter = ("created_at",)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@admin.register(Textbook)
|
|
18
|
+
class TextbookAdmin(admin.ModelAdmin):
|
|
19
|
+
list_display = ("id", "title", "subject", "shelf_code", "is_available_admin")
|
|
20
|
+
list_filter = ("subject",)
|
|
21
|
+
search_fields = ("title", "shelf_code")
|
|
22
|
+
inlines = [BorrowInline]
|
|
23
|
+
list_select_related = ("subject",)
|
|
24
|
+
|
|
25
|
+
@admin.display(boolean=True, description="Доступен")
|
|
26
|
+
def is_available_admin(self, obj):
|
|
27
|
+
return not obj.borrows.filter(status=Borrow.STATUS_ACTIVE).exists()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@admin.action(description="Отметить как возвращённые")
|
|
31
|
+
def mark_returned(modeladmin, request, queryset):
|
|
32
|
+
from django.utils import timezone
|
|
33
|
+
|
|
34
|
+
queryset.filter(status=Borrow.STATUS_ACTIVE).update(
|
|
35
|
+
status=Borrow.STATUS_RETURNED, returned_at=timezone.now()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@admin.register(Borrow)
|
|
40
|
+
class BorrowAdmin(admin.ModelAdmin):
|
|
41
|
+
list_display = ("id", "student", "textbook", "status", "due_date", "borrowed_at")
|
|
42
|
+
list_filter = ("status", "due_date", "student")
|
|
43
|
+
search_fields = ("student__email", "textbook__title", "textbook__shelf_code")
|
|
44
|
+
actions = [mark_returned]
|
|
45
|
+
date_hierarchy = "borrowed_at"
|
|
46
|
+
list_select_related = ("student", "textbook", "textbook__subject")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Generated by Django 6.0.6 on 2026-06-15 01:06
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='Borrow',
|
|
16
|
+
fields=[
|
|
17
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
18
|
+
('borrowed_at', models.DateTimeField(auto_now_add=True)),
|
|
19
|
+
('due_date', models.DateField()),
|
|
20
|
+
('returned_at', models.DateTimeField(blank=True, null=True)),
|
|
21
|
+
('status', models.PositiveSmallIntegerField(choices=[(0, 'active'), (1, 'returned'), (2, 'overdue')], default=0)),
|
|
22
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
23
|
+
],
|
|
24
|
+
),
|
|
25
|
+
migrations.CreateModel(
|
|
26
|
+
name='Subject',
|
|
27
|
+
fields=[
|
|
28
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
29
|
+
('name', models.CharField(max_length=255, unique=True)),
|
|
30
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
31
|
+
],
|
|
32
|
+
),
|
|
33
|
+
migrations.CreateModel(
|
|
34
|
+
name='Textbook',
|
|
35
|
+
fields=[
|
|
36
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
37
|
+
('title', models.CharField(db_index=True, max_length=255)),
|
|
38
|
+
('author', models.CharField(max_length=255)),
|
|
39
|
+
('shelf_code', models.CharField(max_length=64)),
|
|
40
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
41
|
+
],
|
|
42
|
+
),
|
|
43
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Generated by Django 6.0.6 on 2026-06-15 01:06
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
('libraly', '0001_initial'),
|
|
14
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='borrow',
|
|
20
|
+
name='student',
|
|
21
|
+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='borrows', to=settings.AUTH_USER_MODEL),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name='textbook',
|
|
25
|
+
name='subject',
|
|
26
|
+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='textbooks', to='libraly.subject'),
|
|
27
|
+
),
|
|
28
|
+
migrations.AddField(
|
|
29
|
+
model_name='borrow',
|
|
30
|
+
name='textbook',
|
|
31
|
+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='borrows', to='libraly.textbook'),
|
|
32
|
+
),
|
|
33
|
+
migrations.AddIndex(
|
|
34
|
+
model_name='textbook',
|
|
35
|
+
index=models.Index(fields=['subject', 'title'], name='libraly_tex_subject_eefda8_idx'),
|
|
36
|
+
),
|
|
37
|
+
migrations.AlterUniqueTogether(
|
|
38
|
+
name='textbook',
|
|
39
|
+
unique_together={('subject', 'shelf_code')},
|
|
40
|
+
),
|
|
41
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.db.models import Q
|
|
4
|
+
from rest_framework.authentication import get_user_model
|
|
5
|
+
|
|
6
|
+
User = get_user_model()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Subject(models.Model):
|
|
10
|
+
name = models.CharField(max_length=255, unique=True)
|
|
11
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
12
|
+
|
|
13
|
+
def __str__(self):
|
|
14
|
+
return self.name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Textbook(models.Model):
|
|
18
|
+
subject = models.ForeignKey(Subject, on_delete=models.CASCADE, related_name="textbooks")
|
|
19
|
+
title = models.CharField(max_length=255, db_index=True)
|
|
20
|
+
author = models.CharField(max_length=255)
|
|
21
|
+
shelf_code = models.CharField(max_length=64)
|
|
22
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
23
|
+
|
|
24
|
+
class Meta:
|
|
25
|
+
unique_together = ("subject", "shelf_code")
|
|
26
|
+
indexes = [
|
|
27
|
+
models.Index(fields=["subject", "title"]),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
return f"{self.title} ({self.shelf_code})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Borrow(models.Model):
|
|
35
|
+
STATUS_ACTIVE = 0
|
|
36
|
+
STATUS_RETURNED = 1
|
|
37
|
+
STATUS_OVERDUE = 2
|
|
38
|
+
STATUS_CHOICES = (
|
|
39
|
+
(STATUS_ACTIVE, "active"),
|
|
40
|
+
(STATUS_RETURNED, "returned"),
|
|
41
|
+
(STATUS_OVERDUE, "overdue"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
student = models.ForeignKey(
|
|
45
|
+
User,
|
|
46
|
+
on_delete=models.CASCADE,
|
|
47
|
+
related_name="borrows",
|
|
48
|
+
)
|
|
49
|
+
textbook = models.ForeignKey(
|
|
50
|
+
Textbook,
|
|
51
|
+
on_delete=models.CASCADE,
|
|
52
|
+
related_name="borrows",
|
|
53
|
+
)
|
|
54
|
+
borrowed_at = models.DateTimeField(auto_now_add=True)
|
|
55
|
+
due_date = models.DateField()
|
|
56
|
+
returned_at = models.DateTimeField(null=True, blank=True)
|
|
57
|
+
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE)
|
|
58
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
59
|
+
|
|
60
|
+
def __str__(self):
|
|
61
|
+
return f"{self.student} -> {self.textbook}"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
|
2
|
+
from django.contrib.auth import get_user_model
|
|
3
|
+
|
|
4
|
+
User = get_user_model()
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class IsAdmin(BasePermission):
|
|
8
|
+
def has_permission(self, request, view):
|
|
9
|
+
return (
|
|
10
|
+
request.user
|
|
11
|
+
and request.user.is_authenticated
|
|
12
|
+
and request.user.role == User.ROLE_ADMIN
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IsLibrarian(BasePermission):
|
|
17
|
+
def has_permission(self, request, view):
|
|
18
|
+
return (
|
|
19
|
+
request.user
|
|
20
|
+
and request.user.is_authenticated
|
|
21
|
+
and request.user.role in (User.ROLE_LIBRARIAN, User.ROLE_ADMIN)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IsStudent(BasePermission):
|
|
26
|
+
def has_permission(self, request, view):
|
|
27
|
+
return (
|
|
28
|
+
request.user
|
|
29
|
+
and request.user.is_authenticated
|
|
30
|
+
and request.user.role == User.ROLE_STUDENT
|
|
31
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from rest_framework import serializers
|
|
2
|
+
from django.contrib.auth import get_user_model
|
|
3
|
+
|
|
4
|
+
from .models import Subject, Textbook, Borrow
|
|
5
|
+
|
|
6
|
+
User = get_user_model()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SubjectSerializer(serializers.ModelSerializer):
|
|
10
|
+
class Meta:
|
|
11
|
+
model = Subject
|
|
12
|
+
fields = [
|
|
13
|
+
"id",
|
|
14
|
+
"name",
|
|
15
|
+
"created_at"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TextbookSerializer(serializers.ModelSerializer):
|
|
20
|
+
subject_name = serializers.CharField(source="subject.name", read_only=True)
|
|
21
|
+
is_available = serializers.SerializerMethodField()
|
|
22
|
+
|
|
23
|
+
class Meta:
|
|
24
|
+
model = Textbook
|
|
25
|
+
fields = [
|
|
26
|
+
"id",
|
|
27
|
+
"subject",
|
|
28
|
+
"subject_name",
|
|
29
|
+
"title",
|
|
30
|
+
"author",
|
|
31
|
+
"shelf_code",
|
|
32
|
+
"is_available",
|
|
33
|
+
"created_at",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def get_is_available(self, obj):
|
|
37
|
+
return not obj.borrows.filter(status=Borrow.STATUS_ACTIVE).exists()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BorrowSerializer(serializers.ModelSerializer):
|
|
41
|
+
class Meta:
|
|
42
|
+
model = Borrow
|
|
43
|
+
fields = [
|
|
44
|
+
"id",
|
|
45
|
+
"student",
|
|
46
|
+
"textbook",
|
|
47
|
+
"borrowed_at",
|
|
48
|
+
"due_date",
|
|
49
|
+
"returned_at",
|
|
50
|
+
"status",
|
|
51
|
+
"created_at",
|
|
52
|
+
]
|
|
53
|
+
read_only_fields = ["borrowed_at", "returned_at", "status", "created_at"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.contrib.auth import get_user_model
|
|
3
|
+
from rest_framework.test import APIClient
|
|
4
|
+
from libraly.models import Subject, Textbook, Borrow
|
|
5
|
+
|
|
6
|
+
User = get_user_model()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def api_client():
|
|
11
|
+
return APIClient()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def admin_user(db):
|
|
16
|
+
user = User.objects.create_user(
|
|
17
|
+
email="admin@test.ru",
|
|
18
|
+
password="admin123",
|
|
19
|
+
role=User.ROLE_ADMIN,
|
|
20
|
+
is_staff=True,
|
|
21
|
+
is_superuser=True,
|
|
22
|
+
)
|
|
23
|
+
return user
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def librarian_user(db):
|
|
28
|
+
user = User.objects.create_user(
|
|
29
|
+
email="librarian@test.ru",
|
|
30
|
+
password="lib123",
|
|
31
|
+
role=User.ROLE_LIBRARIAN,
|
|
32
|
+
is_staff=True,
|
|
33
|
+
)
|
|
34
|
+
return user
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def student_user(db):
|
|
39
|
+
user = User.objects.create_user(
|
|
40
|
+
email="student@test.ru",
|
|
41
|
+
password="student123",
|
|
42
|
+
role=User.ROLE_STUDENT,
|
|
43
|
+
)
|
|
44
|
+
return user
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def subject(db):
|
|
49
|
+
return Subject.objects.create(name="Математика")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def textbook(db, subject):
|
|
54
|
+
return Textbook.objects.create(
|
|
55
|
+
subject=subject,
|
|
56
|
+
title="Алгебра 8 класс",
|
|
57
|
+
author="Мордкович",
|
|
58
|
+
shelf_code="MAT-001",
|
|
59
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.db import IntegrityError
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from libraly.models import Subject, Textbook, Borrow
|
|
5
|
+
|
|
6
|
+
User = get_user_model()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.django_db
|
|
10
|
+
def test_textbook_unique_together(subject):
|
|
11
|
+
Textbook.objects.create(
|
|
12
|
+
subject=subject,
|
|
13
|
+
title="Алгебра 8 класс",
|
|
14
|
+
author="Мордкович",
|
|
15
|
+
shelf_code="MAT-001",
|
|
16
|
+
)
|
|
17
|
+
with pytest.raises(IntegrityError):
|
|
18
|
+
Textbook.objects.create(
|
|
19
|
+
subject=subject,
|
|
20
|
+
title="Алгебра 8 класс",
|
|
21
|
+
author="Мордкович",
|
|
22
|
+
shelf_code="MAT-001",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.django_db
|
|
27
|
+
def test_borrow_unique_constraints_active(textbook):
|
|
28
|
+
student = User.objects.create_user(
|
|
29
|
+
email="stud1@test.ru", password="pass", role=User.ROLE_STUDENT
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
Borrow.objects.create(
|
|
33
|
+
student=student,
|
|
34
|
+
textbook=textbook,
|
|
35
|
+
due_date="2026-06-30",
|
|
36
|
+
status=Borrow.STATUS_ACTIVE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Вторая активная для того же student+textbook -> IntegrityError
|
|
40
|
+
with pytest.raises(IntegrityError):
|
|
41
|
+
Borrow.objects.create(
|
|
42
|
+
student=student,
|
|
43
|
+
textbook=textbook,
|
|
44
|
+
due_date="2026-06-30",
|
|
45
|
+
status=Borrow.STATUS_ACTIVE,
|
|
46
|
+
)
|