From b45660fbcf5dd22188354bfa0193845e568bda53 Mon Sep 17 00:00:00 2001 From: Mole Shang <135e2@135e2.dev> Date: Sat, 24 Jan 2026 19:47:41 +0800 Subject: initial commit --- store/__init__.py | 0 store/admin.py | 18 + store/apps.py | 5 + store/forms.py | 61 ++++ store/migrations/0001_initial.py | 248 ++++++++++++++ store/migrations/0002_create_superuser.py | 26 ++ store/migrations/__init__.py | 0 store/models.py | 59 ++++ store/templates/store/.book_list.html.kate-swp | 0 store/templates/store/add_edit_book.html | 84 +++++ store/templates/store/base.html | 83 +++++ store/templates/store/book_detail.html | 69 ++++ store/templates/store/book_list.html | 221 +++++++++++++ store/templates/store/cart.html | 52 +++ store/templates/store/checkout.html | 47 +++ store/templates/store/delete_book_confirm.html | 16 + store/templates/store/import_books.html | 46 +++ store/templates/store/login.html | 32 ++ store/templates/store/order_list.html | 56 ++++ store/templates/store/order_management.html | 111 +++++++ store/templates/store/profile.html | 39 +++ store/templates/store/register.html | 49 +++ store/tests.py | 3 + store/urls.py | 24 ++ store/views.py | 433 +++++++++++++++++++++++++ 25 files changed, 1782 insertions(+) create mode 100644 store/__init__.py create mode 100644 store/admin.py create mode 100644 store/apps.py create mode 100644 store/forms.py create mode 100644 store/migrations/0001_initial.py create mode 100644 store/migrations/0002_create_superuser.py create mode 100644 store/migrations/__init__.py create mode 100644 store/models.py create mode 100644 store/templates/store/.book_list.html.kate-swp create mode 100644 store/templates/store/add_edit_book.html create mode 100644 store/templates/store/base.html create mode 100644 store/templates/store/book_detail.html create mode 100644 store/templates/store/book_list.html create mode 100644 store/templates/store/cart.html create mode 100644 store/templates/store/checkout.html create mode 100644 store/templates/store/delete_book_confirm.html create mode 100644 store/templates/store/import_books.html create mode 100644 store/templates/store/login.html create mode 100644 store/templates/store/order_list.html create mode 100644 store/templates/store/order_management.html create mode 100644 store/templates/store/profile.html create mode 100644 store/templates/store/register.html create mode 100644 store/tests.py create mode 100644 store/urls.py create mode 100644 store/views.py (limited to 'store') diff --git a/store/__init__.py b/store/__init__.py new file mode 100644 index 0000000..e69de29 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 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 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 %} +
+ +

{{ title }}

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + + {% for field in form %} + {% if field.name != 'cover_url' and field.name != 'cover_upload' %} +
+ +
+ {% endif %} + {% endfor %} + + + URL + File Upload + + +
+ +
+
+ +
+ + {% if form.cover_url.errors or form.cover_upload.errors or form.non_field_errors %} +
+ {{ form.cover_url.errors }} + {{ form.cover_upload.errors }} + {{ form.non_field_errors }} +
+ {% endif %} + +
+ Cancel + Save +
+
+{% endblock %} + +{% block extra_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 @@ + + + + + + SEU Bookstore + + + + + + + + + + + SEU Bookstore + + + {% if user.is_authenticated and not user.avatar %}{{ user.username|first|upper }}{% endif %} + + {% if user.is_authenticated %} + Profile + Logout + {% else %} + Login + Register + {% endif %} + + + + + + Books + Cart + Orders + +
+ + {% if user.is_staff %} + Manage + {% endif %} +
+ + + +
+ {% block content %} + {% endblock %} +
+
+
+ + + {% block extra_script %}{% endblock %} + + + 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 %} +
+
+ {% if book.cover %} + {{ book.title }} + {% else %} +
+ No Image Available +
+ {% endif %} +
+
+
+

{{ book.title }}

+

{{ book.author }}

+

Publisher: {{ book.publisher }}

+

ISBN: {{ book.isbn }}

+

Price: {{ book.price }}

+

Stock: {{ book.stock }}

+

{{ book.description|safe }}

+
+ +
+
+ {% csrf_token %} + + Add to Cart +
+ Back to List + + {% if user.is_staff %} + Edit + Delete + {% endif %} +
+
+
+ + + +
+

Comments

+ {% if user.is_authenticated %} +
+ {% csrf_token %} + + Post Comment +
+ {% else %} +

Login to post a comment.

+ {% endif %} + +
+ {% for comment in comments %} + +
+ {{ comment.user.username }} + {{ comment.createdAt|date:"M d, Y H:i" }} +
+
{{ comment.content }}
+
+ {% empty %} +

No comments yet.

+ {% endfor %} +
+
+{% 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 %} +
+
+

Books

+
+ Search + {% if user.is_staff %} + Import Books + Add Book + {% endif %} +
+
+ +
+ + Search + {% if query %} + Clear + {% endif %} +
+
+ +
+ {% for book in books %} + +
+ {% if book.cover %} + {{ book.title }} + {% else %} +
+ No Image +
+ {% endif %} + +
+
+

+ {{ book.title }} +

+

+ {{ book.author }} +

+
+ +
+ {{ book.price }}¥ +
+
+ {% csrf_token %} + Add to Cart +
+
+
+
+
+
+ {% empty %} +
+ +
+

No books yet

+ {% if query %} +

We couldn't find any books matching "{{ query }}".

+ Clear Search + {% else %} +

Check back later!

+ {% endif %} +
+
+ {% endfor %} +
+ +{% if books.paginator.num_pages > 1 %} +
+
+
+ + {% if books.has_previous %}{{ books.number|sub:1 }}{% endif %} + +
+ +
+ + + of {{ books.paginator.num_pages }} + +
+ +
+ + {% if books.has_next %}{{ books.number|add:1 }}{% endif %} + +
+
+
+{% endif %} +{% endblock %} + +{% block extra_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 %} +

Shopping Cart

+ {% if cart_items %} +
+ + + + + + + + + + + + {% for item in cart_items %} + + + + + + + + {% endfor %} + + + + + + + + +
BookPriceQuantityTotalActions
{{ item.book.title }}{{ item.book.price }}¥ +
+ {% csrf_token %} + + Update +
+
{{ item.total_price }}¥ + Remove +
Total:{{ total_price }}¥
+
+
+ Checkout +
+ {% else %} +

Your cart is empty.

+ Go Shopping + {% 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 %} +

Checkout

+
+
+

Order Summary

+ + + + + + + + + + + {% for item in cart_items %} + + + + + + + {% endfor %} + + + + + + + +
BookPriceQuantityTotal
{{ item.book.title }}${{ item.book.price }}{{ item.quantity }}${{ item.total_price }}
Total:${{ total_price }}
+
+
+

Shipping Information

+
+ {% csrf_token %} +
+ +
+ Place Order +
+
+
+{% 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 %} +
+ +

Confirm Deletion

+

Are you sure you want to delete "{{ book.title }}"?

+
+ {% csrf_token %} + Cancel + Delete +
+
+
+{% 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 %} +
+ +

{{ title }}

+
+

Upload a JSONL file to import books. Each line should be a JSON object with a "metadata" key containing book details.

+
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + +
+
+ + + Select JSONL File +
No file selected
+
+
+ +
+ Cancel + Import +
+
+
+
+{% endblock %} + +{% block extra_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 %} +
+ +

Login

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + +
+ +
+
+ +
+ + Login +
+
+

Don't have an account? Register

+
+
+
+{% 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 %} +

My Orders

+ {% if orders %} + + {% for order in orders %} + + + Order #{{ order.id }} - {{ order.createdAt|date:"M d, Y" }} - {{ order.total_price }}¥ - + {% if order.status == 1 %} + Pending + {% elif order.status == 2 %} + Shipped + {% elif order.status == 3 %} + Rejected + {% endif %} + +
+

Shipping Address: {{ order.address }}

+ + + + + + + + + + + {% for item in order.items.all %} + + + + + + + {% endfor %} + +
BookPriceQuantitySubtotal
{{ item.book.title }}{{ item.bookPrice }}¥{{ item.amount }}{{ item.total_price }}¥
+
+
+ {% endfor %} +
+ {% else %} +

You haven't placed any orders yet.

+ {% 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 %} +

Order Management

+ +
+ + + + + + + + + + + + + + + {% for order in orders %} + + + + + + + + + + + + + + {% endfor %} + +
IDBuyerAddressDateTotalStatusActions
#{{ order.id }}{{ order.buyer.username }} +
+ {{ order.address }} +
+
{{ order.createdAt|date:"M d, Y H:i" }}${{ order.total_price }} + {% if order.status == 1 %} + Pending + {% elif order.status == 2 %} + Shipped + {% elif order.status == 3 %} + Rejected + {% endif %} + +
+ {% if order.status == 1 %} +
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+ {% else %} + {{ order.get_status_display }} + {% endif %} +
+
+ +
+
+{% endblock %} + +{% block extra_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 %} +
+ +

User Profile

+ +
+ {% csrf_token %} +
+ {% if user.avatar %} + Avatar + {% else %} +
+ {{ user.username|first|upper }} +
+ {% endif %} +
+ +
+
+ + +
+ + + + + + +
+ +
+ Update Profile +
+
+
+
+{% 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 %} +
+ +

Register

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + + {% for field in form %} +
+ + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + + {% if field.errors %} +
+ {% for error in field.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ {% endfor %} + + Register +
+
+

Already have an account? Login

+
+
+
+{% 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//', views.book_detail, name='book_detail'), + path('cart/', views.view_cart, name='cart_detail'), + path('cart/add//', views.add_to_cart, name='add_to_cart'), + path('cart/remove//', views.remove_from_cart, name='remove_from_cart'), + path('cart/update//', 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//', 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//edit/', views.edit_book, name='edit_book'), + path('book//delete/', views.delete_book, name='delete_book'), + path('book//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") -- cgit v1.2.3