diff options
| author | Mole Shang <135e2@135e2.dev> | 2026-01-24 19:47:41 +0800 |
|---|---|---|
| committer | Mole Shang <135e2@135e2.dev> | 2026-01-24 19:47:41 +0800 |
| commit | b45660fbcf5dd22188354bfa0193845e568bda53 (patch) | |
| tree | 172825ef6e210ce03fc2241395c6cbc389538a2b /store | |
| download | seu-bookstore-b45660fbcf5dd22188354bfa0193845e568bda53.tar.gz seu-bookstore-b45660fbcf5dd22188354bfa0193845e568bda53.tar.bz2 seu-bookstore-b45660fbcf5dd22188354bfa0193845e568bda53.zip | |
initial commit
Diffstat (limited to 'store')
25 files changed, 1782 insertions, 0 deletions
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") |
