aboutsummaryrefslogtreecommitdiff
path: root/store
diff options
context:
space:
mode:
authorMole Shang <135e2@135e2.dev>2026-01-24 19:47:41 +0800
committerMole Shang <135e2@135e2.dev>2026-01-24 19:47:41 +0800
commitb45660fbcf5dd22188354bfa0193845e568bda53 (patch)
tree172825ef6e210ce03fc2241395c6cbc389538a2b /store
downloadseu-bookstore-b45660fbcf5dd22188354bfa0193845e568bda53.tar.gz
seu-bookstore-b45660fbcf5dd22188354bfa0193845e568bda53.tar.bz2
seu-bookstore-b45660fbcf5dd22188354bfa0193845e568bda53.zip
initial commit
Diffstat (limited to 'store')
-rw-r--r--store/__init__.py0
-rw-r--r--store/admin.py18
-rw-r--r--store/apps.py5
-rw-r--r--store/forms.py61
-rw-r--r--store/migrations/0001_initial.py248
-rw-r--r--store/migrations/0002_create_superuser.py26
-rw-r--r--store/migrations/__init__.py0
-rw-r--r--store/models.py59
-rw-r--r--store/templates/store/.book_list.html.kate-swp0
-rw-r--r--store/templates/store/add_edit_book.html84
-rw-r--r--store/templates/store/base.html83
-rw-r--r--store/templates/store/book_detail.html69
-rw-r--r--store/templates/store/book_list.html221
-rw-r--r--store/templates/store/cart.html52
-rw-r--r--store/templates/store/checkout.html47
-rw-r--r--store/templates/store/delete_book_confirm.html16
-rw-r--r--store/templates/store/import_books.html46
-rw-r--r--store/templates/store/login.html32
-rw-r--r--store/templates/store/order_list.html56
-rw-r--r--store/templates/store/order_management.html111
-rw-r--r--store/templates/store/profile.html39
-rw-r--r--store/templates/store/register.html49
-rw-r--r--store/tests.py3
-rw-r--r--store/urls.py24
-rw-r--r--store/views.py433
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")