diff options
37 files changed, 2126 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cedd0d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# data files +*.jsonl +*.sqlite3* +media/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/bookstore_project/__init__.py b/bookstore_project/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/bookstore_project/__init__.py diff --git a/bookstore_project/asgi.py b/bookstore_project/asgi.py new file mode 100644 index 0000000..c542d2f --- /dev/null +++ b/bookstore_project/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for bookstore_project 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", "bookstore_project.settings") + +application = get_asgi_application() diff --git a/bookstore_project/settings.py b/bookstore_project/settings.py new file mode 100644 index 0000000..7f8b1c5 --- /dev/null +++ b/bookstore_project/settings.py @@ -0,0 +1,128 @@ +""" +Django settings for temp_project project. + +Generated by 'django-admin startproject' using Django 6.0.1. + +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-mpnnw2d=87-u)6+vmidshrn2)2(lt497%j+4w$4*t^nv840nwk" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "mathfilters", + "store", +] + +AUTH_USER_MODEL = "store.User" + +LOGIN_URL = "/login" + + +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 = "bookstore_project.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 = "bookstore_project.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/" + +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + diff --git a/bookstore_project/urls.py b/bookstore_project/urls.py new file mode 100644 index 0000000..0cfd748 --- /dev/null +++ b/bookstore_project/urls.py @@ -0,0 +1,26 @@ +""" +URL configuration for bookstore_project 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 +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("store.urls")), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/bookstore_project/wsgi.py b/bookstore_project/wsgi.py new file mode 100644 index 0000000..64b0c49 --- /dev/null +++ b/bookstore_project/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for bookstore_project 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", "bookstore_project.settings") + +application = get_wsgi_application() @@ -0,0 +1,6 @@ +def main(): + print("Hello from bookstore!") + + +if __name__ == "__main__": + main() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..598c795 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/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", "bookstore_project.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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d67fc9d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "bookstore" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "django>=6.0.1", + "django-mathfilters>=1.0.0", + "pillow>=12.1.0", +] diff --git a/store/__init__.py b/store/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/store/__init__.py diff --git a/store/admin.py b/store/admin.py new file mode 100644 index 0000000..e010c58 --- /dev/null +++ b/store/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from .models import Book, User, Order, OrderItem, Comment + +class UserAdmin(BaseUserAdmin): + fieldsets = BaseUserAdmin.fieldsets + ( + (None, {'fields': ('avatar', 'phone', 'address', 'name', 'roles')}), + ) + add_fieldsets = BaseUserAdmin.add_fieldsets + ( + (None, {'fields': ('email', 'phone', 'address', 'name')}), + ) + list_display = BaseUserAdmin.list_display + ('phone', 'is_staff') + +admin.site.register(Book) +admin.site.register(User, UserAdmin) +admin.site.register(Order) +admin.site.register(OrderItem) +admin.site.register(Comment)
\ No newline at end of file diff --git a/store/apps.py b/store/apps.py new file mode 100644 index 0000000..c567eb0 --- /dev/null +++ b/store/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class StoreConfig(AppConfig): + name = "store" diff --git a/store/forms.py b/store/forms.py new file mode 100644 index 0000000..1a07b6b --- /dev/null +++ b/store/forms.py @@ -0,0 +1,61 @@ +from django import forms +from django.conf import settings +from django.contrib.auth.forms import UserCreationForm +from django.core.files.storage import default_storage +from .models import User, Book +import os +import hashlib +import io + +class UserRegisterForm(UserCreationForm): + class Meta: + model = User + fields = ['username', 'email', 'phone', 'address', 'name'] + + def save(self, commit=True): + user = super().save(commit=False) + user.roles = ['user'] + if commit: + user.save() + return user + +class BookForm(forms.ModelForm): + cover_url = forms.URLField(required=False, label="Cover Image URL") + cover_upload = forms.ImageField(required=False, label="Cover Image File Upload") + + class Meta: + model = Book + fields = ['title', 'author', 'publisher', 'price', 'stock', 'isbn', 'description'] + + def clean(self): + cleaned_data = super().clean() + cover_url = cleaned_data.get("cover_url") + cover_upload = cleaned_data.get("cover_upload") + if cover_url and cover_upload: + raise forms.ValidationError("Please provide either a URL or an upload, not both.") + return cleaned_data + + def save(self, commit=True): + instance = super().save(commit=False) + cover_url = self.cleaned_data.get('cover_url') + cover_upload = self.cleaned_data.get('cover_upload') + + if cover_url: + instance.cover = cover_url + elif cover_upload: + file_content = cover_upload.read() + file_hash = hashlib.md5(file_content).hexdigest()[:16] # Using first 16 chars for brevity + cover_upload.seek(0) + + name, ext = os.path.splitext(cover_upload.name) + unique_filename = f"{name}_{file_hash}{ext}" + + file_name = default_storage.save(os.path.join('covers', unique_filename), cover_upload) + instance.cover = os.path.join(settings.MEDIA_URL, file_name) + + if commit: + instance.save() + return instance + +class ImportBookForm(forms.Form): + file = forms.FileField(label="JSONL File") diff --git a/store/migrations/0001_initial.py b/store/migrations/0001_initial.py new file mode 100644 index 0000000..e966d1b --- /dev/null +++ b/store/migrations/0001_initial.py @@ -0,0 +1,248 @@ +# Generated by Django 6.0.1 on 2026-01-24 11:45 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="Book", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("author", models.CharField(max_length=255)), + ("publisher", models.CharField(max_length=255)), + ("price", models.IntegerField()), + ("stock", models.IntegerField()), + ("isbn", models.CharField(max_length=20, unique=True)), + ("cover", models.CharField(blank=True, max_length=2048, null=True)), + ("description", models.TextField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "avatar", + models.ImageField(blank=True, null=True, upload_to="avatars/"), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("phone", models.CharField(max_length=20, unique=True)), + ("address", models.CharField(blank=True, max_length=255, null=True)), + ("name", models.CharField(blank=True, max_length=255, null=True)), + ("roles", models.JSONField(default=list)), + ( + "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", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("content", models.TextField()), + ("createdAt", models.DateTimeField(auto_now_add=True)), + ( + "book", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="store.book" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Order", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("address", models.CharField(max_length=255)), + ("createdAt", models.DateTimeField(auto_now_add=True)), + ( + "status", + models.SmallIntegerField( + choices=[(1, "Pending"), (2, "Shipped"), (3, "Rejected")], + default=1, + ), + ), + ( + "buyer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="OrderItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.IntegerField()), + ("bookPrice", models.IntegerField()), + ( + "book", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="store.book" + ), + ), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="store.order", + ), + ), + ], + ), + ] diff --git a/store/migrations/0002_create_superuser.py b/store/migrations/0002_create_superuser.py new file mode 100644 index 0000000..4f322d6 --- /dev/null +++ b/store/migrations/0002_create_superuser.py @@ -0,0 +1,26 @@ +from django.db import migrations +import os + +def create_superuser(apps, schema_editor): + User = apps.get_model('store', 'User') + db_alias = schema_editor.connection.alias + + if not User.objects.using(db_alias).filter(username='admin').exists(): + User.objects.create_superuser( + username=os.environ.get('DJANGO_SUPERUSER_USERNAME', 'admin'), + email=os.environ.get('DJANGO_SUPERUSER_EMAIL', 'admin@example.com'), + password=os.environ.get('DJANGO_SUPERUSER_PASSWORD', 'admin'), + is_staff=True, + is_superuser=True, + roles=['user', 'admin'] + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('store', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_superuser, migrations.RunPython.noop), + ] diff --git a/store/migrations/__init__.py b/store/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/store/migrations/__init__.py diff --git a/store/models.py b/store/models.py new file mode 100644 index 0000000..9e321fc --- /dev/null +++ b/store/models.py @@ -0,0 +1,59 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + +class User(AbstractUser): + avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) + email = models.EmailField(unique=True) + phone = models.CharField(max_length=20, unique=True) + address = models.CharField(max_length=255, null=True, blank=True) + name = models.CharField(max_length=255, null=True, blank=True) + roles = models.JSONField(default=list) + + def save(self, *args, **kwargs): + if not self.roles: + self.roles = ['user'] + if (self.is_staff or self.is_superuser) and 'admin' not in self.roles: + self.roles.append('admin') + super().save(*args, **kwargs) + +class Book(models.Model): + title = models.CharField(max_length=255) + author = models.CharField(max_length=255) + publisher = models.CharField(max_length=255) + price = models.IntegerField() + stock = models.IntegerField() + isbn = models.CharField(max_length=20, unique=True) + cover = models.CharField(max_length=2048, null=True, blank=True) + description = models.TextField(null=True, blank=True) + + +class Order(models.Model): + STATUS_CHOICES = [ + (1, 'Pending'), + (2, 'Shipped'), + (3, 'Rejected'), + ] + buyer = models.ForeignKey(User, on_delete=models.CASCADE) + address = models.CharField(max_length=255) + createdAt = models.DateTimeField(auto_now_add=True) + status = models.SmallIntegerField(choices=STATUS_CHOICES, default=1) + + @property + def total_price(self): + return sum(item.total_price for item in self.items.all()) + +class OrderItem(models.Model): + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items') + book = models.ForeignKey(Book, on_delete=models.CASCADE) + amount = models.IntegerField() + bookPrice = models.IntegerField() + + @property + def total_price(self): + return self.bookPrice * self.amount + +class Comment(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + book = models.ForeignKey(Book, on_delete=models.CASCADE) + content = models.TextField() + createdAt = models.DateTimeField(auto_now_add=True) diff --git a/store/templates/store/.book_list.html.kate-swp b/store/templates/store/.book_list.html.kate-swp new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/store/templates/store/.book_list.html.kate-swp diff --git a/store/templates/store/add_edit_book.html b/store/templates/store/add_edit_book.html new file mode 100644 index 0000000..c87a9c7 --- /dev/null +++ b/store/templates/store/add_edit_book.html @@ -0,0 +1,84 @@ +{% extends 'store/base.html' %} +{% load static %} + +{% block content %} +<div style="display: flex; justify-content: center; margin-top: 3rem; margin-bottom: 3rem;"> + <mdui-card style="width: 100%; max-width: 600px; padding: 2rem;"> + <h2 style="margin-top: 0; text-align: center;">{{ title }}</h2> + <form method="POST" enctype="multipart/form-data"> + {% csrf_token %} + {% if form.non_field_errors %} + <div style="color: var(--mdui-color-error); margin-bottom: 1rem;"> + {% for error in form.non_field_errors %} + <p>{{ error }}</p> + {% endfor %} + </div> + {% endif %} + + {% for field in form %} + {% if field.name != 'cover_url' and field.name != 'cover_upload' %} + <div style="margin-bottom: 1rem;"> + <mdui-text-field + label="{{ field.label }}" + name="{{ field.name }}" + value="{{ field.value|default:'' }}" + {% if field.field.required %}required{% endif %} + {% if field.name == 'description' %}rows="5"{% endif %} + {% if field.field.widget.input_type == 'number' %}type="number"{% endif %} + style="width: 100%;" + ></mdui-text-field> + </div> + {% endif %} + {% endfor %} + + <mdui-select label="Cover Image Type" value="url" style="margin-top: 0.5rem; margin-bottom: 1rem; width: 100%;" onchange="toggleCoverInput(this.value);"> + <mdui-menu-item value="url">URL</mdui-menu-item> + <mdui-menu-item value="file">File Upload</mdui-menu-item> + </mdui-select> + + <div id="cover-url-input" style="margin-bottom: 1rem;"> + <mdui-text-field label="{{ form.cover_url.label }}" name="{{ form.cover_url.name }}" value="{{ form.cover_url.value|default:'' }}" style="width: 100%;"></mdui-text-field> + </div> + <div id="cover-file-input"> + <input type="file" name="{{ form.cover_upload.name }}" style="display: none"> + </div> + + {% if form.cover_url.errors or form.cover_upload.errors or form.non_field_errors %} + <div style="color: var(--mdui-color-error); font-size: 0.875rem; margin-top: 0.25rem;"> + {{ form.cover_url.errors }} + {{ form.cover_upload.errors }} + {{ form.non_field_errors }} + </div> + {% endif %} + + <div style="display: flex; gap: 1rem; margin-top: 2rem; justify-content: flex-end;"> + <mdui-button href="{% if book %}{% url 'book_detail' book.id %}{% else %}{% url 'book_list' %}{% endif %}" variant="text" icon="cancel">Cancel</mdui-button> + <mdui-button type="submit" icon="save">Save</mdui-button> + </div> + </form> +{% endblock %} + +{% block extra_script %} + <script> + function toggleCoverInput(type) { + const urlInput = document.getElementById('cover-url-input'); + const fileInput = document.getElementById('cover-file-input'); + const urlField = urlInput.querySelector('mdui-text-field'); + const fileField = fileInput.querySelector('input'); + + if (type === 'url') { + urlInput.style.display = 'block'; + urlField.disabled = false; + } else { + urlInput.style.display = 'none'; + urlField.disabled = true; + fileField.showPicker(); + } + } + // Initialize on page load + document.addEventListener('DOMContentLoaded', () => { + const select = document.querySelector('mdui-select'); + toggleCoverInput(select.value); + }); + </script> +{% endblock %} diff --git a/store/templates/store/base.html b/store/templates/store/base.html new file mode 100644 index 0000000..18a9557 --- /dev/null +++ b/store/templates/store/base.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<html lang="en" class="mdui-theme-auto"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>SEU Bookstore</title> + <link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css"> + <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> + <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet"> + <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> + <style> + html, body { height: 100%; margin: 0; padding: 0; } + body { font-family: 'Roboto', sans-serif; } + .main-container { max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; } + </style> + <script src="https://unpkg.com/mdui@2/mdui.global.js"></script> +</head> +<body> + <mdui-layout full-height> + <mdui-top-app-bar variant="center-aligned" style="margin: 0 2rem;"> + <mdui-top-app-bar-title href="/">SEU Bookstore</mdui-top-app-bar-title> + + <mdui-dropdown> + <mdui-avatar slot="trigger" {% if user.is_authenticated and user.avatar %}src="{{ user.avatar.url }}"{% else %}icon="people_alt"{% endif %} style="cursor: pointer;">{% if user.is_authenticated and not user.avatar %}{{ user.username|first|upper }}{% endif %}</mdui-avatar> + <mdui-menu> + {% if user.is_authenticated %} + <mdui-menu-item href="{% url 'profile' %}" icon="person">Profile</mdui-menu-item> + <mdui-menu-item href="{% url 'logout' %}" icon="logout">Logout</mdui-menu-item> + {% else %} + <mdui-menu-item href="{% url 'login' %}" icon="login">Login</mdui-menu-item> + <mdui-menu-item href="{% url 'register' %}" icon="person_add">Register</mdui-menu-item> + {% endif %} + </mdui-menu> + </mdui-dropdown> + </mdui-top-app-bar> + + <mdui-navigation-rail divider> + <mdui-navigation-rail-item icon="library_books" href="{% url 'book_list' %}">Books</mdui-navigation-rail-item> + <mdui-navigation-rail-item icon="shopping_cart" href="{% url 'cart_detail' %}">Cart</mdui-navigation-rail-item> + <mdui-navigation-rail-item icon="receipt_long" href="{% url 'order_list' %}">Orders</mdui-navigation-rail-item> + + <div style="flex-grow: 1"></div> + + {% if user.is_staff %} + <mdui-navigation-rail-item slot="bottom" href="{% url 'order_management' %}" icon="local_shipping">Manage</mdui-navigation-rail-item> + {% endif %} + </mdui-navigation-rail> + + + <mdui-layout-main> + <div class="main-container"> + {% block content %} + {% endblock %} + </div> + </mdui-layout-main> + </mdui-layout> + + <script> + window.mdui.setColorScheme("#9ecaff"); + document.addEventListener('DOMContentLoaded', function () { + const currentPath = window.location.pathname; + const railItems = document.querySelectorAll('mdui-navigation-rail-item'); + + railItems.forEach(item => { + if (item.getAttribute('href') === currentPath) { + item.active = true; + } + }); + + {% if messages %} + {% for message in messages %} + const snackbar = mdui.snackbar({ + message: '{{ message }}', + closeable: true + }); + {% endfor %} + {% endif %} + }); + </script> + {% block extra_script %}{% endblock %} +</body> +</html> + diff --git a/store/templates/store/book_detail.html b/store/templates/store/book_detail.html new file mode 100644 index 0000000..e9cc2b8 --- /dev/null +++ b/store/templates/store/book_detail.html @@ -0,0 +1,69 @@ +{% extends 'store/base.html' %} + +{% block content %} + <div style="display: flex; gap: 2rem; flex-wrap: wrap; margin-bottom: 2rem;"> + <div style="flex: 1; min-width: 300px; max-width: 400px;"> + {% if book.cover %} + <img src="{{ book.cover }}" alt="{{ book.title }}" style="width: 100%; border-radius: 12px; box-shadow: var(--mdui-elevation-level2);"> + {% else %} + <div style="width: 100%; height: 400px; background-color: var(--mdui-color-surface-container-highest); display: flex; align-items: center; justify-content: center; border-radius: 12px; color: var(--mdui-color-on-surface-variant);"> + No Image Available + </div> + {% endif %} + </div> + <div style="flex: 2; min-width: 300px;"> + <div class="mdui-prose"> + <h1>{{ book.title }}</h1> + <h3>{{ book.author }}</h3> + <p><strong>Publisher:</strong> {{ book.publisher }}</p> + <p><strong>ISBN:</strong> {{ book.isbn }}</p> + <p><strong>Price:</strong> {{ book.price }}</p> + <p><strong>Stock:</strong> {{ book.stock }}</p> + <p>{{ book.description|safe }}</p> + </div> + + <div style="margin-top: 1.5rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap;"> + <form action="{% url 'add_to_cart' book.id %}" method="POST" style="display: flex; align-items: center; gap: 1rem;"> + {% csrf_token %} + <mdui-text-field type="number" name="quantity" value="1" min="1" max="{{ book.stock }}" style="width: 80px;"></mdui-text-field> + <mdui-button type="submit" variant="filled" icon="shopping_cart">Add to Cart</mdui-button> + </form> + <mdui-button variant="outlined" href="{% url 'book_list' %}" icon="arrow_back">Back to List</mdui-button> + + {% if user.is_staff %} + <mdui-button variant="tonal" href="{% url 'edit_book' book.id %}" icon="edit">Edit</mdui-button> + <mdui-button variant="text" style="color: var(--mdui-color-error);" href="{% url 'delete_book' book.id %}" icon="delete">Delete</mdui-button> + {% endif %} + </div> + </div> + </div> + + <mdui-divider></mdui-divider> + + <div style="margin-top: 2rem;"> + <h2>Comments</h2> + {% if user.is_authenticated %} + <form action="{% url 'post_comment' book.id %}" method="POST" style="margin-bottom: 2rem; max-width: 600px;"> + {% csrf_token %} + <mdui-text-field name="content" label="Add a comment" rows="3" required style="width: 100%; margin-bottom: 1rem;"></mdui-text-field> + <mdui-button type="submit" icon="send">Post Comment</mdui-button> + </form> + {% else %} + <p><a href="{% url 'login' %}">Login</a> to post a comment.</p> + {% endif %} + + <div style="display: flex; flex-direction: column; gap: 1rem;"> + {% for comment in comments %} + <mdui-card variant="outlined" style="padding: 1rem;"> + <div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;"> + <span style="font-weight: bold;">{{ comment.user.username }}</span> + <span style="color: var(--mdui-color-on-surface-variant); font-size: 0.9em;">{{ comment.createdAt|date:"M d, Y H:i" }}</span> + </div> + <div>{{ comment.content }}</div> + </mdui-card> + {% empty %} + <p>No comments yet.</p> + {% endfor %} + </div> + </div> +{% endblock %} diff --git a/store/templates/store/book_list.html b/store/templates/store/book_list.html new file mode 100644 index 0000000..5d68340 --- /dev/null +++ b/store/templates/store/book_list.html @@ -0,0 +1,221 @@ +{% extends 'store/base.html' %} +{% load mathfilters %} +{% block content %} +<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem"> + <div style="display: flex; justify-content: space-between; align-items: center;"> + <h1>Books</h1> + <div style="display: flex; gap: 0.5rem; align-items: center"> + <mdui-button icon="search" id="search-toggle">Search</mdui-button> + {% if user.is_staff %} + <mdui-button href="{% url 'import_books' %}" icon="upload">Import Books</mdui-button> + <mdui-button href="{% url 'add_book' %}" icon="add">Add Book</mdui-button> + {% endif %} + </div> + </div> + + <form id="search-form" method="GET" style="display: {% if query %}flex{% else %}none{% endif %}; gap: 1rem; align-items: flex-start;"> + <mdui-text-field + name="q" + value="{{ query|default:'' }}" + placeholder="Search by title or author" + icon="search" + style="flex-grow: 1;" + clearable + ></mdui-text-field> + <mdui-button type="submit" variant="filled" icon="search">Search</mdui-button> + {% if query %} + <mdui-button href="{% url 'book_list' %}" variant="text" icon="clear">Clear</mdui-button> + {% endif %} + </form> +</div> + +<div style=" + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; + "> + {% for book in books %} + <mdui-card clickable id="{% url 'book_detail' book.id %}"> + <div style="display: flex; flex-direction: column"> + {% if book.cover %} + <img src="{{ book.cover }}" alt="{{ book.title }}" + style="width: 100%; height: 250px; object-fit: cover; flex-shrink: 0" /> + {% else %} + <div style=" + height: 250px; + background-color: var(--mdui-color-surface-container-highest); + display: flex; + align-items: center; + justify-content: center; + color: var(--mdui-color-on-surface-variant); + flex-shrink: 0; + "> + No Image + </div> + {% endif %} + + <div style=" + padding: 1rem; + flex-grow: 1; + display: flex; + flex-direction: column; + "> + <div style="margin-bottom: 1rem"> + <h3 style=" + margin: 0 0 0.5rem 0; + font-size: 1.25rem; + line-height: 1.4; + color: var(--mdui-color-on-surface); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + "> + {{ book.title }} + </h3> + <p style=" + margin: 0; + color: var(--mdui-color-on-surface-variant); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + "> + {{ book.author }} + </p> + </div> + + <div style=" + margin-top: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1rem; + border-top: 1px solid var(--mdui-color-surface-container-highest); + "> + <span style="font-weight: bold; font-size: 1.1rem; color: var(--mdui-color-primary);">{{ book.price }}¥</span> + <div> + <form action="{% url 'add_to_cart' book.id %}" method="POST" style="display: inline"> + {% csrf_token %} + <mdui-button type="submit" variant="tonal">Add to Cart</mdui-button> + </form> + </div> + </div> + </div> + </div> + </mdui-card> + {% empty %} + <div style=" + grid-column: 1 / -1; + text-align: center; + padding: 3rem; + color: var(--mdui-color-on-surface-variant); + "> + <mdui-icon name="library_books--outlined" + style="font-size: 4rem; display: block; margin: 0 auto 1rem"></mdui-icon> + <div class="mdui-prose"> + <h2>No books yet</h2> + {% if query %} + <p>We couldn't find any books matching "{{ query }}".</p> + <mdui-button href="{% url 'book_list' %}" variant="text">Clear Search</mdui-button> + {% else %} + <p>Check back later!</p> + {% endif %} + </div> + </div> + {% endfor %} +</div> + +{% if books.paginator.num_pages > 1 %} +<div style=" + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin-top: 3rem; + width: 100%; + "> + <div style=" + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + align-items: center; + width: 100%; + max-width: 600px; + "> + <div style="justify-self: end"> + <mdui-button {% if books.has_previous %}onclick="jumpToPage({{ books.previous_page_number }})" {% else + %}disabled{% endif %} variant="outlined" icon="arrow_back"> + {% if books.has_previous %}{{ books.number|sub:1 }}{% endif %} + </mdui-button> + </div> + + <div style=" + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: center; + "> + <mdui-text-field variant="outlined" id="page-jump-input" value="{{ books.number }}" + style="width: 60px"></mdui-text-field> + <span style="color: var(--mdui-color-on-surface); white-space: nowrap"> + of {{ books.paginator.num_pages }} + </span> + </div> + + <div style="justify-self: start"> + <mdui-button {% if books.has_next %}onclick="jumpToPage({{ books.next_page_number }})" {% else %}disabled{% endif %} variant="outlined" end-icon="arrow_forward"> + {% if books.has_next %}{{ books.number|add:1 }}{% endif %} + </mdui-button> + </div> + </div> +</div> +{% endif %} +{% endblock %} + +{% block extra_script %} +<script> + const jumpToPage = (i) => { + const page = parseInt(i); + if (!isNaN(page)) { + const params = new URLSearchParams(window.location.search); + params.set("page", page); + window.location.search = params.toString(); + } + }; + + document.addEventListener("DOMContentLoaded", function () { + const toggleBtn = document.getElementById("search-toggle"); + const searchForm = document.getElementById("search-form"); + + toggleBtn.addEventListener('click', () => { + if (searchForm.style.display === 'none') { + searchForm.style.display = 'flex'; + } else { + searchForm.style.display = 'none'; + } + }); + + const pageInput = document.getElementById("page-jump-input"); + if (pageInput) { + + pageInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") jumpToPage(pageInput.value); + }); + pageInput.addEventListener("change", () => jumpToPage(pageInput.value)); + } + + const bookCards = document.querySelectorAll("mdui-card"); + bookCards.forEach((card) => { + const form = card.querySelector("form"); + if (form) { + card.addEventListener("click", function (event) { + if (!form.contains(event.target)) { + window.location.href = this.id; + } + }); + } + }); + }); +</script> +{% endblock %} diff --git a/store/templates/store/cart.html b/store/templates/store/cart.html new file mode 100644 index 0000000..f634c58 --- /dev/null +++ b/store/templates/store/cart.html @@ -0,0 +1,52 @@ +{% extends 'store/base.html' %} + +{% block content %} + <h2>Shopping Cart</h2> + {% if cart_items %} + <div style="overflow-x: auto;"> + <table style="width: 100%; border-collapse: collapse; margin-bottom: 2rem;"> + <thead> + <tr style="border-bottom: 1px solid var(--mdui-color-outline-variant); text-align: left;"> + <th style="padding: 1rem;">Book</th> + <th style="padding: 1rem;">Price</th> + <th style="padding: 1rem;">Quantity</th> + <th style="padding: 1rem;">Total</th> + <th style="padding: 1rem;">Actions</th> + </tr> + </thead> + <tbody> + {% for item in cart_items %} + <tr style="border-bottom: 1px solid var(--mdui-color-outline-variant);"> + <td style="padding: 1rem;">{{ item.book.title }}</td> + <td style="padding: 1rem;">{{ item.book.price }}¥</td> + <td style="padding: 1rem;"> + <form action="{% url 'update_cart_quantity' item.book.id %}" method="POST" style="display: flex; align-items: center; gap: 0.5rem;"> + {% csrf_token %} + <input type="number" name="quantity" value="{{ item.quantity }}" min="1" style="width: 60px; padding: 0.5rem; border: 1px solid var(--mdui-color-outline); border-radius: 4px;"> + <mdui-button type="submit" variant="text" icon="refresh">Update</mdui-button> + </form> + </td> + <td style="padding: 1rem;">{{ item.total_price }}¥</td> + <td style="padding: 1rem;"> + <mdui-button href="{% url 'remove_from_cart' item.book.id %}" variant="text" style="color: var(--mdui-color-error);" icon="delete">Remove</mdui-button> + </td> + </tr> + {% endfor %} + </tbody> + <tfoot> + <tr> + <td colspan="3" style="text-align: right; padding: 1rem;"><strong>Total:</strong></td> + <td style="padding: 1rem;"><strong>{{ total_price }}¥</strong></td> + <td></td> + </tr> + </tfoot> + </table> + </div> + <div style="display: flex; justify-content: flex-end;"> + <mdui-button href="{% url 'checkout' %}" variant="filled" icon="payment">Checkout</mdui-button> + </div> + {% else %} + <p>Your cart is empty.</p> + <mdui-button href="{% url 'book_list' %}" variant="filled" icon="shopping_basket">Go Shopping</mdui-button> + {% endif %} +{% endblock %} diff --git a/store/templates/store/checkout.html b/store/templates/store/checkout.html new file mode 100644 index 0000000..11dc99c --- /dev/null +++ b/store/templates/store/checkout.html @@ -0,0 +1,47 @@ +{% extends 'store/base.html' %} + +{% block content %} + <h2>Checkout</h2> + <div style="display: flex; gap: 2rem; flex-wrap: wrap;"> + <div style="flex: 2; min-width: 300px;"> + <h4>Order Summary</h4> + <table style="width: 100%; border-collapse: collapse;"> + <thead> + <tr style="border-bottom: 1px solid var(--mdui-color-outline-variant); text-align: left;"> + <th style="padding: 0.5rem;">Book</th> + <th style="padding: 0.5rem;">Price</th> + <th style="padding: 0.5rem;">Quantity</th> + <th style="padding: 0.5rem;">Total</th> + </tr> + </thead> + <tbody> + {% for item in cart_items %} + <tr style="border-bottom: 1px solid var(--mdui-color-outline-variant);"> + <td style="padding: 0.5rem;">{{ item.book.title }}</td> + <td style="padding: 0.5rem;">${{ item.book.price }}</td> + <td style="padding: 0.5rem;">{{ item.quantity }}</td> + <td style="padding: 0.5rem;">${{ item.total_price }}</td> + </tr> + {% endfor %} + </tbody> + <tfoot> + <tr> + <td colspan="3" style="text-align: right; padding: 0.5rem;"><strong>Total:</strong></td> + <td style="padding: 0.5rem;"><strong>${{ total_price }}</strong></td> + </tr> + </tfoot> + </table> + </div> + <div style="flex: 1; min-width: 300px;"> + <h4>Shipping Information</h4> + <form method="POST"> + {% csrf_token %} + <div style="margin-bottom: 1rem;"> + <mdui-text-field label="Address" name="address" rows="3" required value="{{ user.address|default:'' }}" style="width: 100%;"></mdui-text-field> + </div> + <mdui-button type="submit" full-width icon="check">Place Order</mdui-button> + </form> + </div> + </div> +{% endblock %} + diff --git a/store/templates/store/delete_book_confirm.html b/store/templates/store/delete_book_confirm.html new file mode 100644 index 0000000..8450973 --- /dev/null +++ b/store/templates/store/delete_book_confirm.html @@ -0,0 +1,16 @@ +{% extends 'store/base.html' %} +{% load static %} + +{% block content %} +<div style="display: flex; justify-content: center; margin-top: 3rem;"> + <mdui-card style="width: 100%; max-width: 400px; padding: 2rem;"> + <h2 style="margin-top: 0; text-align: center;">Confirm Deletion</h2> + <p style="text-align: center;">Are you sure you want to delete "<strong>{{ book.title }}</strong>"?</p> + <form method="POST" style="display: flex; justify-content: center; gap: 1rem; margin-top: 2rem;"> + {% csrf_token %} + <mdui-button href="{% url 'book_detail' book.id %}" variant="text" icon="cancel">Cancel</mdui-button> + <mdui-button type="submit" style="background-color: var(--mdui-color-error); color: var(--mdui-color-on-error);" icon="delete">Delete</mdui-button> + </form> + </mdui-card> +</div> +{% endblock %} diff --git a/store/templates/store/import_books.html b/store/templates/store/import_books.html new file mode 100644 index 0000000..81d4f0e --- /dev/null +++ b/store/templates/store/import_books.html @@ -0,0 +1,46 @@ +{% extends 'store/base.html' %} +{% load static %} + +{% block content %} +<div style="display: flex; justify-content: center; margin-top: 3rem; margin-bottom: 3rem;"> + <mdui-card style="width: 100%; max-width: 600px; padding: 2rem;"> + <h2 style="margin-top: 0; text-align: center;">{{ title }}</h2> + <div class="mdui-prose" style="margin-bottom: 1.5rem;"> + <p>Upload a JSONL file to import books. Each line should be a JSON object with a "metadata" key containing book details.</p> + </div> + <form method="POST" enctype="multipart/form-data"> + {% csrf_token %} + {% if form.non_field_errors %} + <div style="color: var(--mdui-color-error); margin-bottom: 1rem;"> + {% for error in form.non_field_errors %} + <p>{{ error }}</p> + {% endfor %} + </div> + {% endif %} + + <div style="margin-bottom: 1.5rem;"> + <div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: center; padding: 2rem; border: 2px dashed var(--mdui-color-outline-variant); border-radius: 12px; background-color: var(--mdui-color-surface-container-low);"> + <mdui-icon name="upload_file" style="font-size: 3rem; color: var(--mdui-color-primary);"></mdui-icon> + <input type="file" name="file" id="jsonl-file-input" accept=".jsonl,.json" required style="display: none;"> + <mdui-button type="button" variant="tonal" onclick="document.getElementById('jsonl-file-input').click()">Select JSONL File</mdui-button> + <div id="file-name-display" style="margin-top: 0.5rem; color: var(--mdui-color-on-surface-variant); font-size: 0.9rem;">No file selected</div> + </div> + </div> + + <div style="display: flex; gap: 1rem; margin-top: 2rem; justify-content: flex-end;"> + <mdui-button href="{% url 'book_list' %}" variant="text">Cancel</mdui-button> + <mdui-button type="submit" icon="upload">Import</mdui-button> + </div> + </form> + </mdui-card> +</div> +{% endblock %} + +{% block extra_script %} + <script> + document.getElementById('jsonl-file-input').addEventListener('change', function(e) { + const fileName = e.target.files[0] ? e.target.files[0].name : 'No file selected'; + document.getElementById('file-name-display').textContent = fileName; + }); + </script> +{% endblock %} diff --git a/store/templates/store/login.html b/store/templates/store/login.html new file mode 100644 index 0000000..ff34b5e --- /dev/null +++ b/store/templates/store/login.html @@ -0,0 +1,32 @@ +{% extends 'store/base.html' %} +{% load static %} + +{% block content %} +<div style="display: flex; justify-content: center; margin-top: 3rem;"> + <mdui-card style="width: 100%; max-width: 400px; padding: 2rem;"> + <h2 style="margin-top: 0; text-align: center;">Login</h2> + <form method="POST"> + {% csrf_token %} + {% if form.non_field_errors %} + <div style="color: var(--mdui-color-error); margin-bottom: 1rem;"> + {% for error in form.non_field_errors %} + <p>{{ error }}</p> + {% endfor %} + </div> + {% endif %} + + <div style="margin-bottom: 1rem;"> + <mdui-text-field label="Username" name="username" required style="width: 100%;"></mdui-text-field> + </div> + <div style="margin-bottom: 1.5rem;"> + <mdui-text-field label="Password" name="password" type="password" required style="width: 100%;"></mdui-text-field> + </div> + + <mdui-button type="submit" full-width icon="login">Login</mdui-button> + </form> + <div style="margin-top: 1rem; text-align: center;"> + <p>Don't have an account? <a href="{% url 'register' %}" style="color: var(--mdui-color-primary);">Register</a></p> + </div> + </mdui-card> +</div> +{% endblock %} diff --git a/store/templates/store/order_list.html b/store/templates/store/order_list.html new file mode 100644 index 0000000..c45680e --- /dev/null +++ b/store/templates/store/order_list.html @@ -0,0 +1,56 @@ +{% extends 'store/base.html' %} + +{% block content %} + <h2>My Orders</h2> + {% if orders %} + <mdui-collapse accordion> + {% for order in orders %} + <mdui-collapse-item> + <mdui-list-item slot="header" + {% if order.status == 1 %} + icon="pending_actions" + {% elif order.status == 2 %} + icon="done_all" + {% elif order.status == 3 %} + icon="error_outline" + {% endif %}> + Order #{{ order.id }} - {{ order.createdAt|date:"M d, Y" }} - {{ order.total_price }}¥ - + {% if order.status == 1 %} + <span style="color: var(--mdui-color-secondary);">Pending</span> + {% elif order.status == 2 %} + <span style="color: var(--mdui-color-success);">Shipped</span> + {% elif order.status == 3 %} + <span style="color: var(--mdui-color-error);">Rejected</span> + {% endif %} + </mdui-list-item> + <div style="padding: 1rem;"> + <p><strong>Shipping Address:</strong> {{ order.address }}</p> + <table style="width: 100%; border-collapse: collapse;"> + <thead> + <tr style="border-bottom: 1px solid var(--mdui-color-outline-variant); text-align: left;"> + <th style="padding: 0.5rem;">Book</th> + <th style="padding: 0.5rem;">Price</th> + <th style="padding: 0.5rem;">Quantity</th> + <th style="padding: 0.5rem;">Subtotal</th> + </tr> + </thead> + <tbody> + {% for item in order.items.all %} + <tr style="border-bottom: 1px solid var(--mdui-color-outline-variant);"> + <td style="padding: 0.5rem;">{{ item.book.title }}</td> + <td style="padding: 0.5rem;">{{ item.bookPrice }}¥</td> + <td style="padding: 0.5rem;">{{ item.amount }}</td> + <td style="padding: 0.5rem;">{{ item.total_price }}¥</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </mdui-collapse-item> + {% endfor %} + </mdui-collapse> + {% else %} + <p>You haven't placed any orders yet.</p> + {% endif %} +{% endblock %} + diff --git a/store/templates/store/order_management.html b/store/templates/store/order_management.html new file mode 100644 index 0000000..18b6e4b --- /dev/null +++ b/store/templates/store/order_management.html @@ -0,0 +1,111 @@ +{% extends 'store/base.html' %} + +{% block content %} + <h2>Order Management</h2> + + <div style="overflow-x: auto; background-color: var(--mdui-color-surface); border-radius: 12px; border: 1px solid var(--mdui-color-outline-variant);"> + <table style="border-collapse: collapse; min-width: 800px;"> + <thead> + <tr style="border-bottom: 1px solid var(--mdui-color-outline-variant); background-color: var(--mdui-color-surface-container);"> + <th style="padding: 1rem; text-align: center; width: 80px;">ID</th> + <th style="padding: 1rem; text-align: center; width: 120px;">Buyer</th> + <th style="padding: 1rem; text-align: left;">Address</th> + <th style="padding: 1rem; text-align: center; width: 180px;">Date</th> + <th style="padding: 1rem; text-align: center; width: 100px;">Total</th> + <th style="padding: 1rem; text-align: center; width: 120px;">Status</th> + <th style="padding: 1rem; text-align: center; width: 160px;">Actions</th> + <th style="padding: 1rem; text-align: center; width: 60px;"></th> + </tr> + </thead> + <tbody> + {% for order in orders %} + <tr style="border-bottom: 1px solid var(--mdui-color-outline-variant); transition: background-color 0.2s;" class="order-row"> + <td style="padding: 1rem; text-align: center;">#{{ order.id }}</td> + <td style="padding: 1rem; text-align: center;">{{ order.buyer.username }}</td> + <td style="padding: 1rem; text-align: left;"> + <div style="max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"> + {{ order.address }} + </div> + </td> + <td style="padding: 1rem; text-align: center;">{{ order.createdAt|date:"M d, Y H:i" }}</td> + <td style="padding: 1rem; text-align: center;">${{ order.total_price }}</td> + <td style="padding: 1rem; text-align: center;"> + {% if order.status == 1 %} + <span style="color: var(--mdui-color-secondary); font-weight: 500;">Pending</span> + {% elif order.status == 2 %} + <span style="color: var(--mdui-color-success); font-weight: 500;">Shipped</span> + {% elif order.status == 3 %} + <span style="color: var(--mdui-color-error); font-weight: 500;">Rejected</span> + {% endif %} + </td> + <td style="padding: 1rem; text-align: center;"> + <div style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;"> + {% if order.status == 1 %} + <form action="{% url 'update_order_status' order.id %}" method="POST" style="display: inline-flex;"> + {% csrf_token %} + <input type="hidden" name="status" value="2"> + <mdui-button-icon type="submit" icon="local_shipping" title="Ship"></mdui-button-icon> + </form> + <form action="{% url 'update_order_status' order.id %}" method="POST" style="display: inline-flex;"> + {% csrf_token %} + <input type="hidden" name="status" value="3"> + <mdui-button-icon type="submit" icon="cancel" style="color: var(--mdui-color-error);" title="Reject"></mdui-button-icon> + </form> + {% else %} + <span style="font-size: 0.8rem; opacity: 0.7;">{{ order.get_status_display }}</span> + {% endif %} + </div> + </td> + <td style="padding: 1rem; text-align: center;"> + <mdui-button-icon icon="expand_more" onclick="toggleDetails({{ order.id }}, this)"></mdui-button-icon> + </td> + </tr> + <tr id="details-{{ order.id }}" style="display: none; background-color: var(--mdui-color-surface-container-low);"> + <td colspan="7" style="padding: 0;"> + <div style="padding: 1.5rem; border-bottom: 1px solid var(--mdui-color-outline-variant);"> + <h4 style="margin: 0 0 1rem 0;">Ordered Items</h4> + <table style="border-collapse: collapse; background-color: var(--mdui-color-surface); border-radius: 8px; overflow: hidden;"> + <thead> + <tr style="background-color: var(--mdui-color-surface-container); font-size: 0.85rem;"> + <th style="padding: 0.75rem; text-align: left;">Book</th> + <th style="padding: 0.75rem; text-align: center;">Price</th> + <th style="padding: 0.75rem; text-align: center;">Quantity</th> + <th style="padding: 0.75rem; text-align: right;">Subtotal</th> + </tr> + </thead> + <tbody> + {% for item in order.items.all %} + <tr style="border-bottom: 1px solid var(--mdui-color-surface-container-high);"> + <td style="padding: 0.75rem;">{{ item.book.title }}</td> + <td style="padding: 0.75rem; text-align: center;">${{ item.bookPrice }}</td> + <td style="padding: 0.75rem; text-align: center;">{{ item.amount }}</td> + <td style="padding: 0.75rem; text-align: right;">${{ item.total_price }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> +{% endblock %} + +{% block extra_script %} + <script> + function toggleDetails(id, btn) { + const row = document.getElementById('details-' + id); + if (row.style.display === 'none') { + row.style.display = 'table-row'; + btn.icon = 'expand_less'; + btn.closest('tr').style.backgroundColor = 'var(--mdui-color-surface-container-low)'; + } else { + row.style.display = 'none'; + btn.icon = 'expand_more'; + btn.closest('tr').style.backgroundColor = ''; + } + } + </script> +{% endblock %} diff --git a/store/templates/store/profile.html b/store/templates/store/profile.html new file mode 100644 index 0000000..d06cc7e --- /dev/null +++ b/store/templates/store/profile.html @@ -0,0 +1,39 @@ +{% extends 'store/base.html' %} + +{% block content %} +<div style="display: flex; justify-content: center; margin-top: 3rem; margin-bottom: 3rem;"> + <mdui-card style="width: 100%; max-width: 600px; padding: 2rem;"> + <h2 style="margin-top: 0; margin-bottom: 2rem; text-align: center;">User Profile</h2> + + <form method="POST" enctype="multipart/form-data" id="profile-form"> + {% csrf_token %} + <div style="text-align: center; margin-bottom: 2rem; position: relative; cursor: pointer;" onclick="document.getElementById('avatar-input').click();"> + {% if user.avatar %} + <img src="{{ user.avatar.url }}" alt="Avatar" style="width: 150px; height: 150px; object-fit: cover; border-radius: 50%; box-shadow: var(--mdui-elevation-level1);"> + {% else %} + <div style="width: 150px; height: 150px; background-color: var(--mdui-color-secondary-container); color: var(--mdui-color-on-secondary-container); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 3rem;"> + {{ user.username|first|upper }} + </div> + {% endif %} + <div style="position: absolute; bottom: 5px; right: calc(50% - 65px); background: rgba(0,0,0,0.5); border-radius: 50%; padding: 5px;"> + <mdui-icon name="edit" style="color: white;"></mdui-icon> + </div> + </div> + <input type="file" name="avatar" id="avatar-input" style="display: none;" onchange="document.getElementById('profile-form').submit();"> + + <div style="display: grid; gap: 1rem;"> + <mdui-text-field label="Username" value="{{ user.username }}" disabled style="width: 100%;"></mdui-text-field> + <mdui-text-field label="Email" value="{{ user.email }}" disabled style="width: 100%;"></mdui-text-field> + + <mdui-text-field label="Full Name" name="name" value="{{ user.name|default:'' }}" style="width: 100%;"></mdui-text-field> + <mdui-text-field label="Phone" name="phone" value="{{ user.phone|default:'' }}" style="width: 100%;"></mdui-text-field> + <mdui-text-field label="Address" name="address" value="{{ user.address|default:'' }}" rows="3" style="width: 100%;"></mdui-text-field> + </div> + + <div style="margin-top: 2rem;"> + <mdui-button type="submit" full-width>Update Profile</mdui-button> + </div> + </form> + </mdui-card> +</div> +{% endblock %} diff --git a/store/templates/store/register.html b/store/templates/store/register.html new file mode 100644 index 0000000..3d68916 --- /dev/null +++ b/store/templates/store/register.html @@ -0,0 +1,49 @@ +{% extends 'store/base.html' %} +{% load static %} + +{% block content %} +<div style="display: flex; justify-content: center; margin-top: 3rem; margin-bottom: 3rem;"> + <mdui-card style="max-width: 500px; padding: 2rem;"> + <h2 style="margin-top: 0; text-align: center;">Register</h2> + <form method="POST"> + {% csrf_token %} + {% if form.non_field_errors %} + <div style="color: var(--mdui-color-error); margin-bottom: 1rem;"> + {% for error in form.non_field_errors %} + <p>{{ error }}</p> + {% endfor %} + </div> + {% endif %} + + {% for field in form %} + <div style="margin-bottom: 1rem;"> + <mdui-text-field + label="{{ field.label }}" + name="{{ field.name }}" + value="{{ field.value|default:'' }}" + {% if field.field.widget.input_type == 'password' %}type="password"{% endif %} + {% if field.field.required %}required{% endif %} + {% if field.name == 'address' %}rows="3"{% endif %} + > + {% if field.help_text %} + <span slot="helper">{{ field.help_text }}</span> + {% endif %} + </mdui-text-field> + {% if field.errors %} + <div style="color: var(--mdui-color-error); font-size: 0.875rem; margin-top: 0.25rem;"> + {% for error in field.errors %} + {{ error }} + {% endfor %} + </div> + {% endif %} + </div> + {% endfor %} + + <mdui-button type="submit" full-width icon="person_add">Register</mdui-button> + </form> + <div style="margin-top: 1rem; text-align: center;"> + <p>Already have an account? <a href="{% url 'login' %}" style="color: var(--mdui-color-primary);">Login</a></p> + </div> + </mdui-card> +</div> +{% endblock %} diff --git a/store/tests.py b/store/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/store/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/store/urls.py b/store/urls.py new file mode 100644 index 0000000..3c52997 --- /dev/null +++ b/store/urls.py @@ -0,0 +1,24 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.book_list, name='book_list'), + path('book/<int:pk>/', views.book_detail, name='book_detail'), + path('cart/', views.view_cart, name='cart_detail'), + path('cart/add/<int:pk>/', views.add_to_cart, name='add_to_cart'), + path('cart/remove/<int:pk>/', views.remove_from_cart, name='remove_from_cart'), + path('cart/update/<int:pk>/', views.update_cart_quantity, name='update_cart_quantity'), + path('checkout/', views.checkout, name='checkout'), + path('orders/', views.order_list, name='order_list'), + path('manage/', views.order_management, name='order_management'), + path('orders/update_status/<int:pk>/', views.update_order_status, name='update_order_status'), + path('book/add/', views.add_book, name='add_book'), + path('book/import/', views.import_books, name='import_books'), + path('book/<int:pk>/edit/', views.edit_book, name='edit_book'), + path('book/<int:pk>/delete/', views.delete_book, name='delete_book'), + path('book/<int:pk>/comment/', views.post_comment, name='post_comment'), + path('profile/', views.profile_view, name='profile'), + path('register/', views.register_view, name='register'), + path('login/', views.login_view, name='login'), + path('logout/', views.logout_view, name='logout'), +] diff --git a/store/views.py b/store/views.py new file mode 100644 index 0000000..e77cd29 --- /dev/null +++ b/store/views.py @@ -0,0 +1,433 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth import login, authenticate, logout +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.contrib.auth.decorators import user_passes_test +from django.core.paginator import Paginator +from django.db.models import Q +from django.db import transaction +from .forms import UserRegisterForm, BookForm, ImportBookForm +from .models import Book, Order, OrderItem, User, Comment + +import json +import threading +import os +import tempfile + +# Global dictionary to track import status: {user_id: {'status': 'running'|'done', 'count': 0}} +import_status = {} + +def process_import_thread(user_id, file_path): + global import_status + count = 0 + try: + with open(file_path, 'r', encoding='utf-8') as f: + for line in f: + try: + data = json.loads(line) + metadata = data.get('metadata') + if not metadata: + continue + + isbn = metadata.get('isbn') + if not isbn: + continue + + defaults = { + 'title': metadata.get('title', 'Unknown Title'), + 'author': metadata.get('author', 'Unknown Author'), + 'publisher': metadata.get('publisher', 'Unknown Publisher'), + 'price': int(metadata.get('price', 50) or 50), + 'stock': 10, + 'description': metadata.get('description', ''), + 'cover': metadata.get('cover', '') + } + + Book.objects.update_or_create( + isbn=isbn, + defaults=defaults + ) + count += 1 + import_status[user_id]['count'] = count + except Exception as e: + print(f"Error importing line: {e}") + continue + except Exception as e: + print(f"File processing error: {e}") + finally: + import_status[user_id]['status'] = 'done' + if os.path.exists(file_path): + os.remove(file_path) + +def is_staff(user): + return user.is_staff or user.is_superuser or (user.roles and 'admin' in user.roles) + +@user_passes_test(is_staff) +def import_books(request): + global import_status + user_id = request.user.id + + if request.method == 'POST': + # Block new import if one is already running + if user_id in import_status and import_status[user_id]['status'] == 'running': + count = import_status[user_id]['count'] + messages.warning(request, f"Import already in progress. {count} books imported so far.") + return redirect('import_books') + + form = ImportBookForm(request.POST, request.FILES) + if form.is_valid(): + uploaded_file = request.FILES['file'] + + # Save to temporary file to read in thread + fd, path = tempfile.mkstemp() + try: + with os.fdopen(fd, 'wb') as tmp: + for chunk in uploaded_file.chunks(): + tmp.write(chunk) + except Exception as e: + os.remove(path) + messages.error(request, f"Error saving file: {e}") + return redirect('import_books') + + # Initialize status + import_status[user_id] = {'status': 'running', 'count': 0} + + # Start thread + thread = threading.Thread(target=process_import_thread, args=(user_id, path)) + thread.daemon = True + thread.start() + + messages.success(request, "Import started in the background. You can continue using the site.") + return redirect('book_list') + else: + form = ImportBookForm() + if user_id in import_status and import_status[user_id]['status'] == 'running': + messages.info(request, f"An import is currently running. {import_status[user_id]['count']} books imported so far.") + + return render(request, 'store/import_books.html', {'form': form, 'title': 'Import Books'}) + +@user_passes_test(is_staff) +def add_book(request): + if request.method == 'POST': + form = BookForm(request.POST, request.FILES) + if form.is_valid(): + form.save() + messages.success(request, "Book added successfully.") + return redirect('book_list') + else: + form = BookForm() + return render(request, 'store/add_edit_book.html', {'form': form, 'title': 'Add Book'}) + +@user_passes_test(is_staff) +def edit_book(request, pk): + book = get_object_or_404(Book, pk=pk) + if request.method == 'POST': + form = BookForm(request.POST, request.FILES, instance=book) + if form.is_valid(): + form.save() + messages.success(request, "Book updated successfully.") + return redirect('book_detail', pk=book.pk) + else: + form = BookForm(instance=book) + return render(request, 'store/add_edit_book.html', {'form': form, 'book': book, 'title': 'Edit Book'}) + +@user_passes_test(is_staff) +def delete_book(request, pk): + book = get_object_or_404(Book, pk=pk) + if request.method == 'POST': + book.delete() + messages.success(request, "Book deleted successfully.") + return redirect('book_list') + return render(request, 'store/delete_book_confirm.html', {'book': book}) + + +@user_passes_test(is_staff) +def order_management(request): + orders = Order.objects.all().order_by('-createdAt') + return render(request, 'store/order_management.html', {'orders': orders}) + +@user_passes_test(is_staff) +def update_order_status(request, pk): + order = get_object_or_404(Order, pk=pk) + if request.method == 'POST': + new_status = request.POST.get('status') + if new_status in ['2', '3'] and str(order.status) != new_status: # 2 for shipped, 3 for rejected + try: + with transaction.atomic(): + if new_status == '2': # Shipped + if order.status == 1: # Only ship if pending + for item in order.items.all(): + book = Book.objects.select_for_update().get(pk=item.book.pk) + # Stock was already reduced at checkout, no need to reduce again + order.status = 2 # Mark as shipped + messages.success(request, f"Order #{order.id} marked as shipped.") + else: + messages.error(request, f"Order #{order.id} cannot be shipped from its current status.") + + elif new_status == '3': # Rejected + if order.status == 1: # Only reject if pending + for item in order.items.all(): + book = Book.objects.select_for_update().get(pk=item.book.pk) + book.stock += item.amount # Add stock back + book.save() + order.status = 3 # Mark as rejected + messages.success(request, f"Order #{order.id} marked as rejected and stock restored.") + else: + messages.error(request, f"Order #{order.id} cannot be rejected from its current status.") + order.save() + + except Exception as e: + messages.error(request, f"Error updating order status: {e}") + else: + messages.error(request, "Invalid status update.") + return redirect('order_management') + +def book_list(request): + query = request.GET.get('q') + search_type = request.GET.get('type', 'title') + + if query: + if search_type == 'author': + books_queryset = Book.objects.filter(author__icontains=query) + elif search_type == 'publisher': + books_queryset = Book.objects.filter(publisher__icontains=query) + elif search_type == 'isbn': + books_queryset = Book.objects.filter(isbn__icontains=query) + else: # Default to title search + books_queryset = Book.objects.filter(title__icontains=query) + else: + books_queryset = Book.objects.all() + + paginator = Paginator(books_queryset, 12) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + return render(request, 'store/book_list.html', { + 'books': page_obj, + 'query': query, + 'search_type': search_type + }) + +def book_detail(request, pk): + book = get_object_or_404(Book, pk=pk) + comments = Comment.objects.filter(book=book).order_by('-createdAt') + return render(request, 'store/book_detail.html', {'book': book, 'comments': comments}) + +@login_required +def add_to_cart(request, pk): + book = get_object_or_404(Book, pk=pk) + if request.method == 'POST': + try: + quantity_to_add = int(request.POST.get('quantity', '1')) + if quantity_to_add <= 0: + raise ValueError + except (ValueError, TypeError): + messages.error(request, "Invalid quantity.") + return redirect(request.META.get('HTTP_REFERER', 'book_list')) + + cart = request.session.get('cart', {}) + current_quantity = cart.get(str(book.id), 0) + + new_quantity = current_quantity + quantity_to_add + + if book.stock < new_quantity: + messages.error(request, f"Sorry, only {book.stock} copies of '{book.title}' are available.") + return redirect(request.META.get('HTTP_REFERER', 'book_list')) + + cart[str(book.id)] = new_quantity + request.session['cart'] = cart + request.session.modified = True + messages.success(request, f"{quantity_to_add} {'copy' if quantity_to_add == 1 else 'copies'} of '{book.title}' added to your cart.") + return redirect('cart_detail') + + return redirect('book_list') + +@login_required +def view_cart(request): + session_cart = request.session.get('cart', {}) + cart_items_data = [] + total_price = 0 + + for book_id, quantity in session_cart.items(): + book = get_object_or_404(Book, pk=book_id) + item_total = book.price * quantity + total_price += item_total + cart_items_data.append({ + 'book': book, + 'quantity': quantity, + 'total_price': item_total, + 'id': book_id # For removing/updating + }) + + return render(request, 'store/cart.html', {'cart_items': cart_items_data, 'total_price': total_price}) + +@login_required +def remove_from_cart(request, pk): + cart = request.session.get('cart', {}) + book_id_str = str(pk) + if book_id_str in cart: + del cart[book_id_str] + request.session['cart'] = cart + request.session.modified = True + messages.success(request, "Item removed from cart.") + return redirect('cart_detail') + +@login_required +def update_cart_quantity(request, pk): + cart = request.session.get('cart', {}) + book_id_str = str(pk) + + if book_id_str not in cart: + messages.error(request, "Item not found in cart.") + return redirect('cart_detail') + + book = get_object_or_404(Book, pk=pk) + + if request.method == 'POST': + try: + quantity = int(request.POST.get('quantity', 1)) + except (ValueError, TypeError): + messages.error(request, "Invalid quantity.") + return redirect('cart_detail') + + if quantity > 0: + if book.stock < quantity: + messages.error(request, f"Sorry, only {book.stock} copies of '{book.title}' are available.") + return redirect('cart_detail') + cart[book_id_str] = quantity + messages.success(request, "Cart updated.") + else: + del cart[book_id_str] + messages.success(request, "Item removed from cart.") + + request.session['cart'] = cart + request.session.modified = True + + return redirect('cart_detail') + +@login_required +def checkout(request): + session_cart = request.session.get('cart', {}) + if not session_cart: + messages.warning(request, "Your cart is empty.") + return redirect('book_list') + + cart_items_data = [] + total_price = 0 + for book_id, quantity in session_cart.items(): + book = get_object_or_404(Book, pk=book_id) + item_total = book.price * quantity + total_price += item_total + cart_items_data.append({ + 'book': book, + 'quantity': quantity, + 'total_price': item_total, + 'id': book_id + }) + + if request.method == 'POST': + address = request.POST.get('address') + if not address: + messages.error(request, "Please provide a shipping address.") + return render(request, 'store/checkout.html', {'cart_items': cart_items_data, 'total_price': total_price}) + + try: + with transaction.atomic(): + # First, verify stock for all items in the current session cart + for item_data in cart_items_data: + book = Book.objects.select_for_update().get(pk=item_data['book'].pk) + if book.stock < item_data['quantity']: + raise ValueError(f"Insufficient stock for '{book.title}'. Only {book.stock} left.") + + order = Order.objects.create(buyer=request.user, address=address) + + for item_data in cart_items_data: + book = Book.objects.get(pk=item_data['book'].pk) + book.stock -= item_data['quantity'] + book.save() + + OrderItem.objects.create( + order=order, + book=book, + amount=item_data['quantity'], + bookPrice=book.price + ) + + request.session['cart'] = {} # Clear the cart + request.session.modified = True + messages.success(request, f"Order #{order.id} placed successfully!") + return redirect('order_list') + + except ValueError as e: + messages.error(request, str(e)) + return redirect('cart_detail') + + return render(request, 'store/checkout.html', {'cart_items': cart_items_data, 'total_price': total_price}) + +@login_required +def order_list(request): + orders = Order.objects.filter(buyer=request.user).order_by('-createdAt') + return render(request, 'store/order_list.html', {'orders': orders}) + +@login_required +def post_comment(request, pk): + book = get_object_or_404(Book, pk=pk) + if request.method == 'POST': + content = request.POST.get('content') + if content: + Comment.objects.create(user=request.user, book=book, content=content) + messages.success(request, "Comment added.") + else: + messages.error(request, "Comment cannot be empty.") + return redirect('book_detail', pk=pk) + +@login_required +def profile_view(request): + if request.method == 'POST': + user = request.user + user.name = request.POST.get('name') + user.phone = request.POST.get('phone') + user.address = request.POST.get('address') + if 'avatar' in request.FILES: + user.avatar = request.FILES['avatar'] + user.save() + messages.success(request, "Profile updated.") + return redirect('profile') + return render(request, 'store/profile.html') + +def register_view(request): + if request.method == 'POST': + form = UserRegisterForm(request.POST) + if form.is_valid(): + user = form.save() + login(request, user) + messages.success(request, "Registration successful.") + return redirect('book_list') + messages.error(request, "Unsuccessful registration. Invalid information.") + else: + form = UserRegisterForm() + return render(request, 'store/register.html', {'form': form}) + +def login_view(request): + if request.method == "POST": + form = AuthenticationForm(request, data=request.POST) + if form.is_valid(): + username = form.cleaned_data.get('username') + password = form.cleaned_data.get('password') + user = authenticate(username=username, password=password) + if user is not None: + login(request, user) + messages.info(request, f"You are now logged in as {username}.") + return redirect("book_list") + else: + messages.error(request, "Invalid username or password.") + else: + messages.error(request, "Invalid username or password.") + form = AuthenticationForm() + return render(request, 'store/login.html', {"form": form}) + +def logout_view(request): + logout(request) + messages.info(request, "You have successfully logged out.") + return redirect("book_list") @@ -0,0 +1,103 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + +[[package]] +name = "bookstore" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "django-mathfilters" }, + { name = "pillow" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=6.0.1" }, + { name = "django-mathfilters", specifier = ">=1.0.0" }, + { name = "pillow", specifier = ">=12.1.0" }, +] + +[[package]] +name = "django" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/016f7e55e855ee738a352b05139d4f8b278d0b451bd01ebef07456ef3b0e/django-6.0.1.tar.gz", hash = "sha256:ed76a7af4da21551573b3d9dfc1f53e20dd2e6c7d70a3adc93eedb6338130a5f", size = 11069565, upload-time = "2026-01-06T18:55:53.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b5/814ed98bd21235c116fd3436a7ed44d47560329a6d694ec8aac2982dbb93/django-6.0.1-py3-none-any.whl", hash = "sha256:a92a4ff14f664a896f9849009cb8afaca7abe0d6fc53325f3d1895a15253433d", size = 8338791, upload-time = "2026-01-06T18:55:46.175Z" }, +] + +[[package]] +name = "django-mathfilters" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a6/1e8262e52aa3cbfae277bc97c23dd4f1f6c7d7c3adaf45fb41132c312a40/django-mathfilters-1.0.0.tar.gz", hash = "sha256:c9b892ef6dfc893683e75cfd0279c187a601ca68f4684c38f9da44657fb64b07", size = 5699, upload-time = "2020-02-10T12:02:06.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/c6/107083a63a564664830e352af330563763654972d27d56e42d9b6e3c744f/django_mathfilters-1.0.0-py3-none-any.whl", hash = "sha256:64200a21bb249fbf27be601d4bbb788779e09c6e063170c097cd82c4d18ebb83", size = 5909, upload-time = "2020-02-10T12:02:09.819Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, + { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, + { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, + { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, +] + +[[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" }, +] |
