generated from westfarn/web_django_template
Add shopper password reset and extend demo seed for accounts, reviews, and tracking.
CI / test (pull_request) Successful in 39s
CI / test (pull_request) Successful in 39s
Buyers can recover locked accounts, and seed_demo now walks the shopper path without live Stripe or EasyPost calls.
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.urls import path
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.urls import path, reverse_lazy
|
||||||
|
|
||||||
from accounts import views
|
from accounts import views
|
||||||
|
from accounts.forms import CustomerPasswordResetForm, CustomerSetPasswordForm
|
||||||
|
|
||||||
app_name = "account"
|
app_name = "account"
|
||||||
|
|
||||||
@@ -11,6 +13,40 @@ urlpatterns = [
|
|||||||
path("logout/", views.customer_logout, name="logout"),
|
path("logout/", views.customer_logout, name="logout"),
|
||||||
path("register/", views.customer_register, name="register"),
|
path("register/", views.customer_register, name="register"),
|
||||||
path("profile/", views.customer_profile, name="profile"),
|
path("profile/", views.customer_profile, name="profile"),
|
||||||
|
path(
|
||||||
|
"password-reset/",
|
||||||
|
auth_views.PasswordResetView.as_view(
|
||||||
|
template_name="accounts/password_reset_form.html",
|
||||||
|
email_template_name="accounts/password_reset_email.txt",
|
||||||
|
subject_template_name="accounts/password_reset_subject.txt",
|
||||||
|
form_class=CustomerPasswordResetForm,
|
||||||
|
success_url=reverse_lazy("account:password_reset_done"),
|
||||||
|
),
|
||||||
|
name="password_reset",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"password-reset/done/",
|
||||||
|
auth_views.PasswordResetDoneView.as_view(
|
||||||
|
template_name="accounts/password_reset_done.html",
|
||||||
|
),
|
||||||
|
name="password_reset_done",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"password-reset/<uidb64>/<token>/",
|
||||||
|
auth_views.PasswordResetConfirmView.as_view(
|
||||||
|
template_name="accounts/password_reset_confirm.html",
|
||||||
|
form_class=CustomerSetPasswordForm,
|
||||||
|
success_url=reverse_lazy("account:password_reset_complete"),
|
||||||
|
),
|
||||||
|
name="password_reset_confirm",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"password-reset/complete/",
|
||||||
|
auth_views.PasswordResetCompleteView.as_view(
|
||||||
|
template_name="accounts/password_reset_complete.html",
|
||||||
|
),
|
||||||
|
name="password_reset_complete",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
if apps.is_installed("shop"):
|
if apps.is_installed("shop"):
|
||||||
|
|||||||
+19
-1
@@ -1,6 +1,11 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
from django.contrib.auth.forms import (
|
||||||
|
AuthenticationForm,
|
||||||
|
PasswordResetForm,
|
||||||
|
SetPasswordForm,
|
||||||
|
UserCreationForm,
|
||||||
|
)
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from contacts.models import Contact
|
from contacts.models import Contact
|
||||||
@@ -72,6 +77,19 @@ class CustomerAuthenticationForm(AuthenticationForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerPasswordResetForm(PasswordResetForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["email"].widget.attrs.update({**_INPUT, "autocomplete": "email"})
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerSetPasswordForm(SetPasswordForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs.update(_INPUT)
|
||||||
|
|
||||||
|
|
||||||
class CustomerProfileForm(forms.Form):
|
class CustomerProfileForm(forms.Form):
|
||||||
first_name = forms.CharField(
|
first_name = forms.CharField(
|
||||||
max_length=150,
|
max_length=150,
|
||||||
|
|||||||
@@ -19,5 +19,9 @@ class PortalStaffMiddleware:
|
|||||||
and getattr(user, "is_authenticated", False)
|
and getattr(user, "is_authenticated", False)
|
||||||
and not getattr(user, "is_staff", False)
|
and not getattr(user, "is_staff", False)
|
||||||
):
|
):
|
||||||
|
from django.apps import apps
|
||||||
|
|
||||||
|
if apps.is_installed("shop"):
|
||||||
return redirect("account:home")
|
return redirect("account:home")
|
||||||
|
return redirect("public:home")
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p>New here? <a href="{% url 'account:register' %}">Create an account</a></p>
|
<p>New here? <a href="{% url 'account:register' %}">Create an account</a>
|
||||||
|
· <a href="{% url 'account:password_reset' %}">Forgot password?</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Password updated · {{ SITE_NAME }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "public/_breadcrumbs.html" with title="Password updated" %}
|
||||||
|
<section class="section section-lg bg-default">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<h3>Password updated</h3>
|
||||||
|
<p>You can sign in with your new password.</p>
|
||||||
|
<p><a class="button button-lg button-primary" href="{% url 'account:login' %}">Sign in</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Choose a new password · {{ SITE_NAME }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "public/_breadcrumbs.html" with title="New password" %}
|
||||||
|
<section class="section section-lg bg-default">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-5">
|
||||||
|
<h3>Choose a new password</h3>
|
||||||
|
{% if validlink %}
|
||||||
|
<form class="rd-form" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
<div class="row row-20 gutter-20">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-wrap">
|
||||||
|
<label class="form-label-outside" for="{{ form.new_password1.id_for_label }}">New password</label>
|
||||||
|
{{ form.new_password1 }}
|
||||||
|
{{ form.new_password1.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-wrap">
|
||||||
|
<label class="form-label-outside" for="{{ form.new_password2.id_for_label }}">Confirm password</label>
|
||||||
|
{{ form.new_password2 }}
|
||||||
|
{{ form.new_password2.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button class="button button-lg button-primary" type="submit">Save password</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>This reset link is invalid or expired. <a href="{% url 'account:password_reset' %}">Request a new one</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Check your email · {{ SITE_NAME }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "public/_breadcrumbs.html" with title="Reset password" %}
|
||||||
|
<section class="section section-lg bg-default">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-6">
|
||||||
|
<h3>Check your email</h3>
|
||||||
|
<p>If an account exists for that address, a reset link is on its way. Check spam if you do not see it.</p>
|
||||||
|
<p><a href="{% url 'account:login' %}">Back to sign in</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% load i18n %}{% autoescape off %}
|
||||||
|
Reset your {{ site_name }} password
|
||||||
|
|
||||||
|
Use this link to choose a new password (it expires):
|
||||||
|
{{ protocol }}://{{ domain }}{% url 'account:password_reset_confirm' uidb64=uid token=token %}
|
||||||
|
|
||||||
|
If you did not ask for a reset, ignore this email.
|
||||||
|
{% endautoescape %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Reset password · {{ SITE_NAME }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "public/_breadcrumbs.html" with title="Reset password" %}
|
||||||
|
<section class="section section-lg bg-default">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8 col-lg-5">
|
||||||
|
<h3>Reset password</h3>
|
||||||
|
<p>Enter the email on your account. We will send a reset link if it matches.</p>
|
||||||
|
<form class="rd-form" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
<div class="row row-20 gutter-20">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-wrap">
|
||||||
|
<label class="form-label-outside" for="{{ form.email.id_for_label }}">Email</label>
|
||||||
|
{{ form.email }}
|
||||||
|
{{ form.email.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button class="button button-lg button-primary" type="submit">Send reset link</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p><a href="{% url 'account:login' %}">Back to sign in</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Password reset for {{ site_name }}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
|
from django.core import mail
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
from django.utils.http import urlsafe_base64_encode
|
||||||
|
|
||||||
from accounts.models import CustomerProfile
|
from accounts.models import CustomerProfile
|
||||||
from shop.models import Order
|
from shop.models import Order
|
||||||
@@ -86,3 +90,39 @@ class CustomerAccountTests(TestCase):
|
|||||||
response = client.get(reverse("dashboard:home"))
|
response = client.get(reverse("dashboard:home"))
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response["Location"], reverse("account:home"))
|
self.assertEqual(response["Location"], reverse("account:home"))
|
||||||
|
|
||||||
|
def test_password_reset_sends_mail_and_sets_new_password(self):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="buyer@example.com",
|
||||||
|
email="buyer@example.com",
|
||||||
|
password="s3cure-pass-123",
|
||||||
|
)
|
||||||
|
client = Client()
|
||||||
|
login_page = client.get(reverse("account:login"))
|
||||||
|
self.assertContains(login_page, reverse("account:password_reset"))
|
||||||
|
posted = client.post(
|
||||||
|
reverse("account:password_reset"),
|
||||||
|
{"email": "buyer@example.com"},
|
||||||
|
)
|
||||||
|
self.assertEqual(posted.status_code, 302)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertIn("password-reset", mail.outbox[0].body)
|
||||||
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
|
token = default_token_generator.make_token(user)
|
||||||
|
confirm_url = reverse(
|
||||||
|
"account:password_reset_confirm",
|
||||||
|
kwargs={"uidb64": uid, "token": token},
|
||||||
|
)
|
||||||
|
bounced = client.get(confirm_url)
|
||||||
|
self.assertEqual(bounced.status_code, 302)
|
||||||
|
set_url = bounced["Location"]
|
||||||
|
saved = client.post(
|
||||||
|
set_url,
|
||||||
|
{
|
||||||
|
"new_password1": "n3wer-pass-456",
|
||||||
|
"new_password2": "n3wer-pass-456",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(saved.status_code, 302)
|
||||||
|
user.refresh_from_db()
|
||||||
|
self.assertTrue(user.check_password("n3wer-pass-456"))
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ DEMO_NOTE = "[demo-seed]"
|
|||||||
DEMO_UTM_CAMPAIGN = "printforge-demo"
|
DEMO_UTM_CAMPAIGN = "printforge-demo"
|
||||||
DEMO_EMAIL_DOMAIN = "@example.com"
|
DEMO_EMAIL_DOMAIN = "@example.com"
|
||||||
DEMO_EMAIL_PREFIX = "demo+"
|
DEMO_EMAIL_PREFIX = "demo+"
|
||||||
|
DEMO_SHOPPER_PASSWORD = "Demo-buyer-pass-123"
|
||||||
|
DEMO_SHOPPER_SLUGS = ("jordan", "casey", "sam")
|
||||||
|
|
||||||
|
|
||||||
def _django_env() -> str:
|
def _django_env() -> str:
|
||||||
@@ -94,8 +96,10 @@ def _stamp(model, pk, when) -> None:
|
|||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = (
|
help = (
|
||||||
"Seed tagged fake catalog, contacts, leads, orders, campaigns, and "
|
"Seed tagged fake catalog, shoppers, reviews, tracking, contacts, "
|
||||||
"analytics for client demos. Allowed on DJANGO_ENV=dev and beta only."
|
"leads, orders, campaigns, and analytics for client demos. "
|
||||||
|
"Allowed on DJANGO_ENV=dev and beta only. Demo shoppers use "
|
||||||
|
f"{DEMO_EMAIL_PREFIX}jordan{DEMO_EMAIL_DOMAIN} / {DEMO_SHOPPER_PASSWORD}."
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
@@ -113,10 +117,17 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
self.verbosity = int(options.get("verbosity", 1))
|
self.verbosity = int(options.get("verbosity", 1))
|
||||||
self.now = timezone.now()
|
self.now = timezone.now()
|
||||||
self.owner = get_user_model().objects.order_by("pk").first()
|
|
||||||
if options["reset"]:
|
if options["reset"]:
|
||||||
self._reset()
|
self._reset()
|
||||||
self.stdout.write("Cleared tagged demo rows.")
|
self.stdout.write("Cleared tagged demo rows.")
|
||||||
|
User = get_user_model()
|
||||||
|
self.owner = User.objects.filter(is_staff=True).order_by("pk").first()
|
||||||
|
if self.owner is None:
|
||||||
|
self.owner = (
|
||||||
|
User.objects.exclude(username__startswith=DEMO_EMAIL_PREFIX)
|
||||||
|
.order_by("pk")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
counts = {
|
counts = {
|
||||||
"contacts": self._seed_contacts(),
|
"contacts": self._seed_contacts(),
|
||||||
"leads": self._seed_leads(),
|
"leads": self._seed_leads(),
|
||||||
@@ -124,7 +135,9 @@ class Command(BaseCommand):
|
|||||||
}
|
}
|
||||||
if apps.is_installed("shop"):
|
if apps.is_installed("shop"):
|
||||||
counts["products"] = self._seed_products()
|
counts["products"] = self._seed_products()
|
||||||
|
counts["shoppers"] = self._seed_shoppers()
|
||||||
counts["orders"] = self._seed_orders()
|
counts["orders"] = self._seed_orders()
|
||||||
|
counts["reviews"] = self._seed_reviews()
|
||||||
if apps.is_installed("shipping"):
|
if apps.is_installed("shipping"):
|
||||||
counts["shipments"] = self._seed_shipments()
|
counts["shipments"] = self._seed_shipments()
|
||||||
if apps.is_installed("payments"):
|
if apps.is_installed("payments"):
|
||||||
@@ -133,6 +146,11 @@ class Command(BaseCommand):
|
|||||||
counts["campaigns"] = self._seed_campaigns()
|
counts["campaigns"] = self._seed_campaigns()
|
||||||
summary = ", ".join(f"{key}={value}" for key, value in counts.items())
|
summary = ", ".join(f"{key}={value}" for key, value in counts.items())
|
||||||
self.stdout.write(self.style.SUCCESS(f"Demo seed ready on {env} ({summary})."))
|
self.stdout.write(self.style.SUCCESS(f"Demo seed ready on {env} ({summary})."))
|
||||||
|
if apps.is_installed("shop"):
|
||||||
|
self.stdout.write(
|
||||||
|
"Demo shopper: "
|
||||||
|
f"{DEMO_EMAIL_PREFIX}jordan{DEMO_EMAIL_DOMAIN} / {DEMO_SHOPPER_PASSWORD}"
|
||||||
|
)
|
||||||
|
|
||||||
def _log(self, message: str) -> None:
|
def _log(self, message: str) -> None:
|
||||||
if self.verbosity >= 2:
|
if self.verbosity >= 2:
|
||||||
@@ -178,6 +196,12 @@ class Command(BaseCommand):
|
|||||||
email__startswith=DEMO_EMAIL_PREFIX,
|
email__startswith=DEMO_EMAIL_PREFIX,
|
||||||
email__endswith=DEMO_EMAIL_DOMAIN,
|
email__endswith=DEMO_EMAIL_DOMAIN,
|
||||||
).delete()
|
).delete()
|
||||||
|
User = get_user_model()
|
||||||
|
User.objects.filter(
|
||||||
|
username__startswith=DEMO_EMAIL_PREFIX,
|
||||||
|
username__endswith=DEMO_EMAIL_DOMAIN,
|
||||||
|
is_staff=False,
|
||||||
|
).delete()
|
||||||
UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).delete()
|
UTMVisit.objects.filter(utm_campaign=DEMO_UTM_CAMPAIGN).delete()
|
||||||
|
|
||||||
def _seed_contacts(self) -> int:
|
def _seed_contacts(self) -> int:
|
||||||
@@ -586,6 +610,50 @@ class Command(BaseCommand):
|
|||||||
self._log(f"product {product.sku}")
|
self._log(f"product {product.sku}")
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
def _seed_shoppers(self) -> int:
|
||||||
|
from accounts.services import get_customer_profile
|
||||||
|
from contacts.models import Contact
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
created = 0
|
||||||
|
for slug in DEMO_SHOPPER_SLUGS:
|
||||||
|
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||||
|
contact = Contact.objects.filter(email=email).first()
|
||||||
|
if contact is None:
|
||||||
|
continue
|
||||||
|
user = User.objects.filter(username__iexact=email).first()
|
||||||
|
if user is None:
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=email,
|
||||||
|
email=email,
|
||||||
|
password=DEMO_SHOPPER_PASSWORD,
|
||||||
|
first_name=contact.first_name,
|
||||||
|
last_name=contact.last_name,
|
||||||
|
is_staff=False,
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
user.first_name = contact.first_name
|
||||||
|
user.last_name = contact.last_name
|
||||||
|
user.email = email
|
||||||
|
user.is_staff = False
|
||||||
|
user.set_password(DEMO_SHOPPER_PASSWORD)
|
||||||
|
user.save(
|
||||||
|
update_fields=[
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"is_staff",
|
||||||
|
"password",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
profile = get_customer_profile(user)
|
||||||
|
profile.phone = contact.phone
|
||||||
|
profile.shipping_address = contact.postal_address or {}
|
||||||
|
profile.save(update_fields=["phone", "shipping_address", "updated_at"])
|
||||||
|
self._log(f"shopper {email}")
|
||||||
|
return created
|
||||||
|
|
||||||
def _seed_orders(self) -> int:
|
def _seed_orders(self) -> int:
|
||||||
from contacts.models import Contact
|
from contacts.models import Contact
|
||||||
from shop.models import Order, OrderItem, Product
|
from shop.models import Order, OrderItem, Product
|
||||||
@@ -635,12 +703,14 @@ class Command(BaseCommand):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
created = 0
|
created = 0
|
||||||
|
User = get_user_model()
|
||||||
for number, slug, status, lines, days_ago in specs:
|
for number, slug, status, lines, days_ago in specs:
|
||||||
contact = Contact.objects.filter(
|
contact = Contact.objects.filter(
|
||||||
email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
email=f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||||
).first()
|
).first()
|
||||||
if contact is None:
|
if contact is None:
|
||||||
continue
|
continue
|
||||||
|
buyer = User.objects.filter(username__iexact=contact.email).first()
|
||||||
amount = sum(
|
amount = sum(
|
||||||
(product.price * qty for product, _color, qty in lines),
|
(product.price * qty for product, _color, qty in lines),
|
||||||
Decimal("0.00"),
|
Decimal("0.00"),
|
||||||
@@ -650,6 +720,7 @@ class Command(BaseCommand):
|
|||||||
order, was_created = Order.objects.update_or_create(
|
order, was_created = Order.objects.update_or_create(
|
||||||
number=number,
|
number=number,
|
||||||
defaults={
|
defaults={
|
||||||
|
"user": buyer,
|
||||||
"email": contact.email,
|
"email": contact.email,
|
||||||
"customer_name": contact.full_name,
|
"customer_name": contact.full_name,
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -679,30 +750,146 @@ class Command(BaseCommand):
|
|||||||
self._log(f"order {order.number} {status}")
|
self._log(f"order {order.number} {status}")
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
def _seed_reviews(self) -> int:
|
||||||
|
from shop.models import Order, Product, ProductReview
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
specs = (
|
||||||
|
(
|
||||||
|
"jordan",
|
||||||
|
"DEMO-ORD-001",
|
||||||
|
"DEMO-FLEXI-DRAGON",
|
||||||
|
5,
|
||||||
|
"Wiggles like crazy",
|
||||||
|
"The cyan flexi dragon is the hit of the desk.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"casey",
|
||||||
|
"DEMO-ORD-002",
|
||||||
|
"DEMO-PLANTER",
|
||||||
|
4,
|
||||||
|
"Sturdy planter",
|
||||||
|
"Held up for STEM night. Would buy again.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sam",
|
||||||
|
"DEMO-ORD-003",
|
||||||
|
"DEMO-D20",
|
||||||
|
5,
|
||||||
|
"Chunky and readable",
|
||||||
|
"Gold face reads well across the table.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created = 0
|
||||||
|
for slug, order_number, sku, rating, title, body in specs:
|
||||||
|
email = f"{DEMO_EMAIL_PREFIX}{slug}{DEMO_EMAIL_DOMAIN}"
|
||||||
|
user = User.objects.filter(username__iexact=email).first()
|
||||||
|
order = Order.objects.filter(number=order_number, user=user).first()
|
||||||
|
product = Product.objects.filter(sku=sku).first()
|
||||||
|
if user is None or order is None or product is None:
|
||||||
|
continue
|
||||||
|
review, was_created = ProductReview.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
product=product,
|
||||||
|
defaults={
|
||||||
|
"order": order,
|
||||||
|
"rating": rating,
|
||||||
|
"title": title,
|
||||||
|
"body": f"{DEMO_NOTE} {body}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
created += 1
|
||||||
|
self._log(f"review {sku} {rating}")
|
||||||
|
return created
|
||||||
|
|
||||||
def _seed_shipments(self) -> int:
|
def _seed_shipments(self) -> int:
|
||||||
from shipping.models import Shipment
|
from shipping.models import Shipment
|
||||||
from shop.models import Order
|
from shop.models import Order
|
||||||
|
|
||||||
order = Order.objects.filter(number="DEMO-ORD-002").first()
|
specs = (
|
||||||
if order is None:
|
(
|
||||||
return 0
|
"DEMO-ORD-002",
|
||||||
shipment, created = Shipment.objects.get_or_create(
|
{
|
||||||
order=order,
|
|
||||||
defaults={
|
|
||||||
"status": Shipment.Status.LABELED,
|
"status": Shipment.Status.LABELED,
|
||||||
"carrier": "USPS",
|
"carrier": "USPS",
|
||||||
"service": "Priority",
|
"service": "Priority",
|
||||||
"tracking_number": "940011189922DEMO02",
|
"tracking_number": "940011189922DEMO02",
|
||||||
|
"tracking_status": Shipment.TrackingStatus.DELIVERED,
|
||||||
|
"tracking_url": (
|
||||||
|
"https://tools.usps.com/go/TrackConfirmAction"
|
||||||
|
"?tLabels=940011189922DEMO02"
|
||||||
|
),
|
||||||
"rate_amount": Decimal("8.45"),
|
"rate_amount": Decimal("8.45"),
|
||||||
"currency": "usd",
|
"currency": "usd",
|
||||||
"weight_oz": 18,
|
"weight_oz": 18,
|
||||||
"notes": f"{DEMO_NOTE} Stub label. No EasyPost call.",
|
"last_tracked_at": self.now - timedelta(hours=6),
|
||||||
|
"tracking_events": [
|
||||||
|
{
|
||||||
|
"status": "in_transit",
|
||||||
|
"message": "Departed USPS facility",
|
||||||
|
"datetime": (self.now - timedelta(days=2)).isoformat(),
|
||||||
|
"location": "Chicago IL",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"status": "delivered",
|
||||||
|
"message": "Delivered, front door",
|
||||||
|
"datetime": (self.now - timedelta(days=1)).isoformat(),
|
||||||
|
"location": "Geneva IL",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"notes": f"{DEMO_NOTE} Stub delivered label. No EasyPost call.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DEMO-ORD-001",
|
||||||
|
{
|
||||||
|
"status": Shipment.Status.LABELED,
|
||||||
|
"carrier": "USPS",
|
||||||
|
"service": "GroundAdvantage",
|
||||||
|
"tracking_number": "940011189922DEMO01",
|
||||||
|
"tracking_status": Shipment.TrackingStatus.IN_TRANSIT,
|
||||||
|
"tracking_url": (
|
||||||
|
"https://tools.usps.com/go/TrackConfirmAction"
|
||||||
|
"?tLabels=940011189922DEMO01"
|
||||||
|
),
|
||||||
|
"rate_amount": Decimal("5.40"),
|
||||||
|
"currency": "usd",
|
||||||
|
"weight_oz": 12,
|
||||||
|
"last_tracked_at": self.now - timedelta(hours=2),
|
||||||
|
"tracking_events": [
|
||||||
|
{
|
||||||
|
"status": "pre_transit",
|
||||||
|
"message": "Shipping label created",
|
||||||
|
"datetime": (self.now - timedelta(days=1)).isoformat(),
|
||||||
|
"location": "Aurora IL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "in_transit",
|
||||||
|
"message": "Arrived at USPS origin facility",
|
||||||
|
"datetime": (self.now - timedelta(hours=8)).isoformat(),
|
||||||
|
"location": "Chicago IL",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"notes": f"{DEMO_NOTE} Stub in-transit label. No EasyPost call.",
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if created:
|
created = 0
|
||||||
self._log(f"shipment {shipment.tracking_number}")
|
for number, defaults in specs:
|
||||||
return 1
|
order = Order.objects.filter(number=number).first()
|
||||||
return 0
|
if order is None:
|
||||||
|
continue
|
||||||
|
shipment = Shipment.objects.filter(order=order).first()
|
||||||
|
if shipment is None:
|
||||||
|
Shipment.objects.create(order=order, **defaults)
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
for key, value in defaults.items():
|
||||||
|
setattr(shipment, key, value)
|
||||||
|
shipment.save()
|
||||||
|
self._log(f"shipment {number} {defaults['tracking_status']}")
|
||||||
|
return created
|
||||||
|
|
||||||
def _seed_invoices(self) -> int:
|
def _seed_invoices(self) -> int:
|
||||||
from contacts.models import Contact
|
from contacts.models import Contact
|
||||||
|
|||||||
@@ -74,6 +74,39 @@ class SeedDemoTests(TestCase):
|
|||||||
listing = Client().get(reverse("shop:list"))
|
listing = Client().get(reverse("shop:list"))
|
||||||
self.assertContains(listing, "Articulated Flexi Dragon")
|
self.assertContains(listing, "Articulated Flexi Dragon")
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from shop.models import ProductReview
|
||||||
|
from shipping.models import Shipment
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
jordan = User.objects.get(username="demo+jordan@example.com")
|
||||||
|
self.assertFalse(jordan.is_staff)
|
||||||
|
self.assertTrue(jordan.check_password("Demo-buyer-pass-123"))
|
||||||
|
self.assertEqual(
|
||||||
|
Order.objects.get(number="DEMO-ORD-001").user_id, jordan.pk
|
||||||
|
)
|
||||||
|
self.assertIsNone(Order.objects.get(number="DEMO-ORD-004").user_id)
|
||||||
|
self.assertTrue(
|
||||||
|
ProductReview.objects.filter(
|
||||||
|
user=jordan, product__sku="DEMO-FLEXI-DRAGON", rating=5
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
delivered = Shipment.objects.get(order__number="DEMO-ORD-002")
|
||||||
|
self.assertEqual(delivered.tracking_status, "delivered")
|
||||||
|
self.assertGreaterEqual(len(delivered.tracking_events), 2)
|
||||||
|
transit = Shipment.objects.get(order__number="DEMO-ORD-001")
|
||||||
|
self.assertEqual(transit.tracking_status, "in_transit")
|
||||||
|
|
||||||
|
client = Client()
|
||||||
|
self.assertTrue(
|
||||||
|
client.login(
|
||||||
|
username="demo+jordan@example.com", password="Demo-buyer-pass-123"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
history = client.get(reverse("account:orders"))
|
||||||
|
self.assertContains(history, "DEMO-ORD-001")
|
||||||
|
self.assertNotContains(history, "DEMO-ORD-004")
|
||||||
|
|
||||||
def test_second_run_is_idempotent(self):
|
def test_second_run_is_idempotent(self):
|
||||||
call_command("seed_demo", stdout=StringIO())
|
call_command("seed_demo", stdout=StringIO())
|
||||||
from shop.models import Product
|
from shop.models import Product
|
||||||
@@ -89,6 +122,11 @@ class SeedDemoTests(TestCase):
|
|||||||
Product.objects.filter(sku="DEMO-FLEXI-DRAGON").delete()
|
Product.objects.filter(sku="DEMO-FLEXI-DRAGON").delete()
|
||||||
call_command("seed_demo", reset=True, stdout=StringIO())
|
call_command("seed_demo", reset=True, stdout=StringIO())
|
||||||
self.assertTrue(Product.objects.filter(sku="DEMO-FLEXI-DRAGON").exists())
|
self.assertTrue(Product.objects.filter(sku="DEMO-FLEXI-DRAGON").exists())
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
get_user_model().objects.filter(username="demo+jordan@example.com").exists()
|
||||||
|
)
|
||||||
|
|
||||||
@patch.dict(os.environ, {"DJANGO_ENV": "beta"})
|
@patch.dict(os.environ, {"DJANGO_ENV": "beta"})
|
||||||
def test_allows_beta(self):
|
def test_allows_beta(self):
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ urlpatterns = [
|
|||||||
path("api/address-suggest/", address_suggest, name="address_suggest"),
|
path("api/address-suggest/", address_suggest, name="address_suggest"),
|
||||||
path("files/", include("core.urls")),
|
path("files/", include("core.urls")),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("account/", include("accounts.customer_urls")),
|
|
||||||
path("accounts/", include("accounts.urls")),
|
path("accounts/", include("accounts.urls")),
|
||||||
path("portal/", include("dashboard.urls")),
|
path("portal/", include("dashboard.urls")),
|
||||||
path("portal/leads/", include("leads.urls")),
|
path("portal/leads/", include("leads.urls")),
|
||||||
@@ -51,6 +50,7 @@ if apps.is_installed("social_ai"):
|
|||||||
]
|
]
|
||||||
if apps.is_installed("shop"):
|
if apps.is_installed("shop"):
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
|
path("account/", include("accounts.customer_urls")),
|
||||||
path("shop/", include("shop.public_urls")),
|
path("shop/", include("shop.public_urls")),
|
||||||
path("portal/shop/", include("shop.portal_urls")),
|
path("portal/shop/", include("shop.portal_urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class UnderConstructionMiddleware:
|
|||||||
"/account/login",
|
"/account/login",
|
||||||
"/account/logout",
|
"/account/logout",
|
||||||
"/account/register",
|
"/account/register",
|
||||||
|
"/account/password-reset",
|
||||||
"/under-construction",
|
"/under-construction",
|
||||||
"/portal/messaging/webhooks/",
|
"/portal/messaging/webhooks/",
|
||||||
"/unsubscribe/",
|
"/unsubscribe/",
|
||||||
|
|||||||
Reference in New Issue
Block a user