Merge branch 'release/3.0.0'

This commit is contained in:
Nathan Chapman 2023-01-21 18:57:42 -07:00
commit 63ad740e44
320 changed files with 56021 additions and 8919 deletions

View File

@ -1 +0,0 @@
mchretien@forum.dk

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
data/

10
.gitignore vendored
View File

@ -131,4 +131,12 @@ dmypy.json
# Pyre type checker
.pyre/
src/media/*
/media/*
/data/
/redis-data/
/static/admin/
/static/debug_toolbar/
/static/CACHE/
/media/
/nginx/certs/

View File

@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [3.0.0] - 2023-01-21
### Added
- Dockerized the entire application
- Integrated Stripe subscriptions
### Changed
- Transitioned from Pipenv to Poetry as package manager
- Updated the entire Dashboard to be mobile friendly and the design to be more consistent
## [1.3.17] - 2022-07-14
### Changed

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM python:3.11-alpine3.17
RUN apk add build-base libpq-dev
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LANG='en_US.UTF-8' LC_ALL='en_US.UTF-8' \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_VERSION=1.2.2
RUN pip install "poetry==$POETRY_VERSION"
WORKDIR /app
COPY poetry.lock pyproject.toml /app/
RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi
COPY . /app/

37
Pipfile
View File

@ -1,37 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
celery = {extras = ["redis"], version = "*"}
Django = "==4.0.2"
django-allauth = "*"
django-anymail = {extras = ["mailgun"], version = "*"}
django-celery-beat = "*"
django-celery-results = "*"
django-compressor = "*"
django-filter = "*"
django-measurement = "*"
django-setup-cli = "*"
django-storages = "*"
django-templated-email = "*"
paypal-checkout-serversdk = "*"
Pillow = "*"
redis = "*"
usps-api = "*"
psycopg2-binary = "*"
gunicorn = "*"
sentry-sdk = "*"
django-localflavor = "*"
django-analytical = "*"
django-simple-captcha = "*"
stripe = "*"
[dev-packages]
django-debug-toolbar = "*"
selenium = "*"
pycodestyle = "*"
[requires]
python_version = "3.10"

1261
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

27
README.md Normal file
View File

@ -0,0 +1,27 @@
```
sudo docker run -it --rm -p 80:80 --name certbot \
-v "/etc/letsencrypt:/etc/letsencrypt" \
-v "/var/lib/letsencrypt:/var/lib/letsencrypt" \
certbot/certbot certonly --standalone -d ptcoffee-dev.windmillapps.org
```
env settings
```
NGINX_CONF=dev.conf
DJANGO_SETTINGS_MODULE=ptcoffee.settings
DB_ENGINE=django.db.backends.postgresql
POSTGRES_DB=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
DB_HOST=db
DB_PORT=5432
CACHE_BACKEND=django.core.cache.backends.redis.RedisCache
CACHE_LOCATION=redis://redis:6379/0
STRIPE_API_KEY=***
PAYPAL_CLIENT_ID=***
PAYPAL_SECRET_ID=***
DEFAULT_FROM_EMAIL=debug@nathanjchapman.com
SERVER_EMAIL=debug@nathanjchapman.com
MAILGUN_API_KEY=***
MAILGUN_SENDER_DOMAIN=***
```

View File

@ -5,7 +5,7 @@ class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
# def ready(self):
# from .signals import (
# user_saved
# )
def ready(self):
from .signals import (
user_saved
)

View File

@ -0,0 +1,34 @@
[{
"model": "accounts.address",
"pk": 3,
"fields": {
"first_name": "Ben",
"last_name": "Cook",
"street_address_1": "1072 CENTER ST",
"street_address_2": "",
"city": "PORT TOWNSEND",
"state": "WA",
"postal_code": "98368"
}
}, {
"model": "accounts.user",
"pk": 2,
"fields": {
"password": "pbkdf2_sha256$320000$wptMSeHwCSQfTiGuZkoRgB$p4c6DXM5fCBC5oRHVIKm5IaryYGIRnacSN0w0oTim/U=",
"last_login": "2022-12-29T12:43:47.411Z",
"is_superuser": false,
"username": "bencook99@gmail.com",
"first_name": "Benjamin",
"last_name": "Cook",
"email": "bencook99@gmail.com",
"is_staff": true,
"is_active": true,
"date_joined": "2022-05-14T09:32:20Z",
"default_shipping_address": 3,
"default_billing_address": null,
"stripe_id": "",
"groups": [],
"user_permissions": [],
"addresses": [3]
}
}]

View File

@ -1,4 +1,4 @@
# Generated by Django 4.0.2 on 2022-03-11 02:25
# Generated by Django 4.1.5 on 2023-01-21 20:20
import django.contrib.auth.models
import django.contrib.auth.validators
@ -25,7 +25,7 @@ class Migration(migrations.Migration):
('street_address_1', models.CharField(blank=True, max_length=256)),
('street_address_2', models.CharField(blank=True, max_length=256)),
('city', models.CharField(blank=True, max_length=256)),
('state', models.CharField(blank=True, choices=[('AL', 'Alabama'), ('AK', 'Alaska'), ('AZ', 'Arizona'), ('AR', 'Arkansas'), ('CA', 'California'), ('CO', 'Colorado'), ('CT', 'Connecticut'), ('DE', 'Delaware'), ('FL', 'Florida'), ('GA', 'Georgia'), ('HI', 'Hawaii'), ('ID', 'Idaho'), ('IL', 'Illinois'), ('IN', 'Indiana'), ('IA', 'Iowa'), ('KS', 'Kansas'), ('KY', 'Kentucky'), ('LA', 'Louisiana'), ('ME', 'Maine'), ('MD', 'Maryland'), ('MA', 'Massachusetts'), ('MI', 'Michigan'), ('MN', 'Minnesota'), ('MS', 'Mississippi'), ('MO', 'Missouri'), ('MT', 'Montana'), ('NE', 'Nebraska'), ('NV', 'Nevada'), ('NH', 'New Hampshire'), ('NJ', 'New Jersey'), ('NM', 'New Mexico'), ('NY', 'New York'), ('NC', 'North Carolina'), ('ND', 'North Dakota'), ('OH', 'Ohio'), ('OK', 'Oklahoma'), ('OR', 'Oregon'), ('PA', 'Pennsylvania'), ('RI', 'Rhode Island'), ('SC', 'South Carolina'), ('SD', 'South Dakota'), ('TN', 'Tennessee'), ('TX', 'Texas'), ('UT', 'Utah'), ('VT', 'Vermont'), ('VA', 'Virginia'), ('WA', 'Washington'), ('WV', 'West Virginia'), ('WI', 'Wisconsin'), ('WY', 'Wyoming')], max_length=2)),
('state', models.CharField(blank=True, choices=[('AL', 'Alabama'), ('AK', 'Alaska'), ('AS', 'American Samoa'), ('AZ', 'Arizona'), ('AR', 'Arkansas'), ('AA', 'Armed Forces Americas'), ('AE', 'Armed Forces Europe'), ('AP', 'Armed Forces Pacific'), ('CA', 'California'), ('CO', 'Colorado'), ('CT', 'Connecticut'), ('DE', 'Delaware'), ('DC', 'District of Columbia'), ('FM', 'Federated States of Micronesia'), ('FL', 'Florida'), ('GA', 'Georgia'), ('GU', 'Guam'), ('HI', 'Hawaii'), ('ID', 'Idaho'), ('IL', 'Illinois'), ('IN', 'Indiana'), ('IA', 'Iowa'), ('KS', 'Kansas'), ('KY', 'Kentucky'), ('LA', 'Louisiana'), ('ME', 'Maine'), ('MH', 'Marshall Islands'), ('MD', 'Maryland'), ('MA', 'Massachusetts'), ('MI', 'Michigan'), ('MN', 'Minnesota'), ('MS', 'Mississippi'), ('MO', 'Missouri'), ('MT', 'Montana'), ('NE', 'Nebraska'), ('NV', 'Nevada'), ('NH', 'New Hampshire'), ('NJ', 'New Jersey'), ('NM', 'New Mexico'), ('NY', 'New York'), ('NC', 'North Carolina'), ('ND', 'North Dakota'), ('MP', 'Northern Mariana Islands'), ('OH', 'Ohio'), ('OK', 'Oklahoma'), ('OR', 'Oregon'), ('PW', 'Palau'), ('PA', 'Pennsylvania'), ('PR', 'Puerto Rico'), ('RI', 'Rhode Island'), ('SC', 'South Carolina'), ('SD', 'South Dakota'), ('TN', 'Tennessee'), ('TX', 'Texas'), ('UT', 'Utah'), ('VT', 'Vermont'), ('VI', 'Virgin Islands'), ('VA', 'Virginia'), ('WA', 'Washington'), ('WV', 'West Virginia'), ('WI', 'Wisconsin'), ('WY', 'Wyoming')], max_length=2)),
('postal_code', models.CharField(blank=True, max_length=20)),
],
),
@ -43,11 +43,12 @@ class Migration(migrations.Migration):
('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')),
('addresses', models.ManyToManyField(blank=True, related_name='user_addresses', to='accounts.Address')),
('stripe_id', models.CharField(blank=True, max_length=255)),
('addresses', models.ManyToManyField(blank=True, related_name='user_addresses', to='accounts.address')),
('default_billing_address', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')),
('default_shipping_address', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')),
('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')),
('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',

View File

@ -1,3 +1,4 @@
import stripe
from django.db import models
from django.urls import reverse
from django.contrib.auth.models import AbstractUser
@ -17,6 +18,18 @@ class Address(models.Model):
)
postal_code = models.CharField(max_length=20, blank=True)
def as_stripe_dict(self):
return {
'name': f'{self.first_name} {self.last_name}',
'address': {
'line1': self.street_address_1,
'line2': self.street_address_2,
'city': self.city,
'state': self.state,
'postal_code': self.postal_code
}
}
def __str__(self):
return f"""
{self.first_name} {self.last_name}
@ -45,3 +58,13 @@ class User(AbstractUser):
Address, related_name="+", null=True, blank=True, on_delete=models.SET_NULL
)
stripe_id = models.CharField(max_length=255, blank=True)
def get_or_create_stripe_id(self):
if not self.stripe_id:
response = stripe.Customer.create(
name=self.first_name + ' ' + self.last_name,
email=self.email
)
self.stripe_id = response['id']
self.save()
return self.stripe_id

View File

@ -8,15 +8,11 @@ from django.conf import settings
from .models import Address, User
logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_API_KEY
@receiver(post_save, sender=User, dispatch_uid='user_saved')
def user_saved(sender, instance, created, **kwargs):
logger.info('User was saved')
if created or not instance.stripe_id:
stripe.api_key = settings.STRIPE_API_KEY
response = stripe.Customer.create(
name=instance.first_name + instance.last_name
)
instance.stripe_id = response['id']
instance.save()
instance.get_or_create_stripe_id()

View File

@ -9,7 +9,7 @@
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Save changes" class="action-button"> or
<input type="submit" value="Save changes" class="btn"> or
<a href="{% url 'account-detail' user.id %}">Cancel</a>
</form>
</section>

View File

@ -4,7 +4,7 @@ from .models import Address, User
from .tasks import send_account_created_email
def get_or_create_customer(request, form, shipping_address):
def get_or_create_customer(request, shipping_address):
address, a_created = Address.objects.get_or_create(
first_name=shipping_address['first_name'],
last_name=shipping_address['last_name'],

View File

@ -190,21 +190,3 @@ class CoffeeGrind:
(PERCOLATOR, 'Percolator'),
(CAFE_STYLE, 'BLTC cafe pour over')
]
def build_usps_rate_request(weight, container, zip_destination):
service = ShippingContainer.get_shipping_service_from_container(container)
return \
{
'service': service,
'zip_origination': settings.DEFAULT_ZIP_ORIGINATION,
'zip_destination': zip_destination,
'pounds': weight,
'ounces': '0',
'container': container,
'width': '',
'length': '',
'height': '',
'girth': '',
'machinable': 'TRUE'
}

View File

@ -11,6 +11,7 @@ from .models import (
ShippingRate,
Order,
Transaction,
Subscription,
OrderLine,
)
@ -24,4 +25,5 @@ admin.site.register(Coupon)
admin.site.register(ShippingRate)
admin.site.register(Order)
admin.site.register(Transaction)
admin.site.register(Subscription)
admin.site.register(OrderLine)

14
core/apps.py Normal file
View File

@ -0,0 +1,14 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'
# def ready(self):
# from .signals import (
# order_created,
# transaction_created,
# order_line_post_save,
# trackingnumber_postsave
# )

View File

@ -9,7 +9,7 @@
"valid_to": "2054-05-31T06:00:00Z",
"discount_value_type": "percentage",
"discount_value": "10.00",
"products": [],
"variants": [],
"users": [1]
}
}, {
@ -23,7 +23,7 @@
"valid_to": "2022-04-30T06:00:00Z",
"discount_value_type": "percentage",
"discount_value": "10.00",
"products": [],
"variants": [],
"users": []
}
}]

View File

@ -7,7 +7,7 @@
"subtitle": "Camper Vacuum Mug",
"description": "Camp mug is made of double-wall stainless steel, designed to keep drinks hot or cold for hours.",
"checkout_limit": 20,
"visible_in_listings": false,
"visible_in_listings": true,
"sorting": 3,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -19,10 +19,10 @@
"product": 10,
"name": "Blue - 12 oz.",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -46,7 +46,7 @@
"subtitle": "Classic BLTC Logo front left and Steam/Waves on back",
"description": "ComfortBlend Hoodie - Screen. EcoSmart® 7.8-oz poly-cotton blend hoodie features a front pouch pocket and two-ply hood with matching drawcord. Rib-knit cuffs and waistband ensure all-day comfort and warmth.",
"checkout_limit": 20,
"visible_in_listings": false,
"visible_in_listings": true,
"sorting": 5,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -58,10 +58,10 @@
"product": 11,
"name": "Blue (Small)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -73,10 +73,10 @@
"product": 11,
"name": "Blue (Medium)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -88,10 +88,10 @@
"product": 11,
"name": "Blue (Large)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -122,7 +122,7 @@
"subtitle": "Logo T-Shirt with Steam/Waves on back",
"description": "5.5 oz. DryBlend 50/50 T-Shirt - Screen. The 5.5-oz 50/50 cotton/polyester blend fabric is moisture-wicking.",
"checkout_limit": 20,
"visible_in_listings": false,
"visible_in_listings": true,
"sorting": 7,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -134,10 +134,10 @@
"product": 12,
"name": "Heather Red (Small)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -149,10 +149,10 @@
"product": 12,
"name": "Heather Red (Medium)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -164,10 +164,10 @@
"product": 12,
"name": "Heather Red (Large)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -179,10 +179,10 @@
"product": 12,
"name": "Heather Blue (Small)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -194,10 +194,10 @@
"product": 12,
"name": "Heather Blue (Medium)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -209,10 +209,10 @@
"product": 12,
"name": "Heather Blue (Large)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -257,7 +257,7 @@
"subtitle": "Washed Cotton Mesh Back Cap with Logo",
"description": "Soft washed cotton gives this trucker hat an appealing vintage look. Designed with ventilated mesh back panels and a front twill sweatband for comfort.",
"checkout_limit": 20,
"visible_in_listings": false,
"visible_in_listings": true,
"sorting": 4,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -269,10 +269,10 @@
"product": 13,
"name": "Black and White",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -296,7 +296,7 @@
"subtitle": "Classic BLTC Cafe Logo without the extra branding",
"description": "Made from a 7.1-oz, 52/48 cotton/polyester blend. Includes a spacious hood with drawcords and metal grommets. Interior phone pocket with headphone cord port.",
"checkout_limit": 20,
"visible_in_listings": false,
"visible_in_listings": true,
"sorting": 6,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -308,10 +308,10 @@
"product": 14,
"name": "Forest Green/Silver Embroidered (Small)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -323,10 +323,10 @@
"product": 14,
"name": "Forest Green/Silver Embroidered (Medium)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -338,10 +338,10 @@
"product": 14,
"name": "Forest Green/Silver Embroidered (Large)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -353,10 +353,10 @@
"product": 14,
"name": "Navy Blue/Silver Embroidered (Small)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -368,10 +368,10 @@
"product": 14,
"name": "Navy Blue/Silver Embroidered (Medium)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -383,10 +383,10 @@
"product": 14,
"name": "Navy Blue/Silver Embroidered (Large)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -398,10 +398,10 @@
"product": 14,
"name": "Black/Gold Embroidered (Small)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -413,10 +413,10 @@
"product": 14,
"name": "Black/Gold Embroidered (Medium)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -428,10 +428,10 @@
"product": 14,
"name": "Black/Gold Embroidered (Large)",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -469,7 +469,7 @@
"subtitle": "Embroidered Buttonless Cap",
"description": "These caps feature a buttonless design that won't interfere with headsets/headphones. The low profile fit and adjustable fabric strap with hook-and-loop closure helps create a more comfortable fit.",
"checkout_limit": 20,
"visible_in_listings": false,
"visible_in_listings": true,
"sorting": 2,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -481,10 +481,10 @@
"product": 15,
"name": "Navy Blue with Red Embroidered Print",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -508,7 +508,7 @@
"subtitle": "Bean/Leaf Logo Neo Vacuum Insulated Cup",
"description": "Designed to keep drinks hot or cold for hours. A great cup for enjoying wine, tea, coffee, hot chocolate, and more!",
"checkout_limit": 20,
"visible_in_listings": false,
"visible_in_listings": true,
"sorting": 1,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -520,10 +520,10 @@
"product": 16,
"name": "Black and Gold 10 oz.",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"
@ -535,10 +535,10 @@
"product": 16,
"name": "White and Black 10 oz.",
"sku": "",
"price": "0.01",
"price": "25.00",
"weight": "00:oz",
"track_inventory": true,
"stock": 0,
"stock": 10,
"sorting": null,
"created_at": "2022-02-23T18:06:57.624Z",
"updated_at": "2022-02-23T18:06:57.624Z"

View File

@ -8,11 +8,11 @@
"billing_address": null,
"shipping_address": 1,
"coupon": null,
"subtotal_amount": "13.40",
"subtotal_amount": "26.80",
"coupon_amount": "0.00",
"shipping_total": "9.55",
"total_amount": "22.95",
"weight": "0.0:oz",
"total_amount": "36.35",
"weight": "32.0:oz",
"created_at": "2022-03-15T17:18:59.584Z",
"updated_at": "2022-03-15T17:18:59.584Z"
}
@ -25,11 +25,11 @@
"billing_address": null,
"shipping_address": 1,
"coupon": null,
"subtotal_amount": "13.40",
"subtotal_amount": "26.80",
"coupon_amount": "0.00",
"shipping_total": "9.55",
"total_amount": "22.95",
"weight": "0.0:oz",
"total_amount": "36.35",
"weight": "32.0:oz",
"created_at": "2022-03-15T17:22:18.440Z",
"updated_at": "2022-03-15T17:22:18.440Z"
}
@ -42,11 +42,11 @@
"billing_address": null,
"shipping_address": 1,
"coupon": null,
"subtotal_amount": "13.40",
"subtotal_amount": "26.80",
"coupon_amount": "0.00",
"shipping_total": "9.55",
"total_amount": "22.95",
"weight": "0.0:oz",
"total_amount": "36.35",
"weight": "32.0:oz",
"created_at": "2022-03-15T17:26:27.869Z",
"updated_at": "2022-03-15T17:26:27.869Z"
}
@ -58,7 +58,7 @@
"variant": 1,
"quantity": 2,
"quantity_fulfilled": 0,
"customer_note": "Grind: Whole Beans; ",
"customer_note": "Grind: Whole Beans",
"currency": "USD",
"unit_price": "13.40",
"tax_rate": "0.00"
@ -71,7 +71,7 @@
"variant": 1,
"quantity": 1,
"quantity_fulfilled": 1,
"customer_note": "Grind: Espresso; ",
"customer_note": "Grind: Espresso",
"currency": "USD",
"unit_price": "13.40",
"tax_rate": "0.00"
@ -84,7 +84,7 @@
"variant": 8,
"quantity": 1,
"quantity_fulfilled": 1,
"customer_note": "Grind: Whole Beans; ",
"customer_note": "Grind: Whole Beans",
"currency": "USD",
"unit_price": "13.40",
"tax_rate": "0.00"
@ -97,7 +97,7 @@
"variant": 7,
"quantity": 1,
"quantity_fulfilled": 1,
"customer_note": "Grind: Cone Drip; ",
"customer_note": "Grind: Cone Drip",
"currency": "USD",
"unit_price": "13.40",
"tax_rate": "0.00"
@ -110,7 +110,7 @@
"variant": 4,
"quantity": 1,
"quantity_fulfilled": 0,
"customer_note": "Grind: Percolator; ",
"customer_note": "Grind: Percolator",
"currency": "USD",
"unit_price": "13.40",
"tax_rate": "0.00"
@ -123,7 +123,7 @@
"variant": 6,
"quantity": 1,
"quantity_fulfilled": 0,
"customer_note": "Grind: Whole Beans; ",
"customer_note": "Grind: Whole Beans",
"currency": "USD",
"unit_price": "13.40",
"tax_rate": "0.00"

View File

@ -17,6 +17,8 @@
"default_shipping_rate": 1,
"free_shipping_min": "100.00",
"max_cart_quantity": 20,
"max_cart_weight": "20:lb"
"max_cart_weight": "20:lb",
"default_contact_email": "support@ptcoffee.com",
"order_from_email": "orders@ptcoffee.com"
}
}]

View File

@ -1,7 +1,6 @@
# Generated by Django 4.0.2 on 2022-10-16 02:36
# Generated by Django 4.1.5 on 2023-01-21 20:20
import core.weight
from decimal import Decimal
from django.conf import settings
import django.contrib.postgres.fields
import django.core.validators
@ -17,8 +16,8 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0003_user_stripe_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('accounts', '0001_initial'),
]
operations = [
@ -33,9 +32,10 @@ class Migration(migrations.Migration):
('valid_to', models.DateTimeField(blank=True, null=True)),
('discount_value_type', models.CharField(choices=[('fixed', 'USD'), ('percentage', '%')], default='fixed', max_length=10)),
('discount_value', models.DecimalField(decimal_places=2, max_digits=12)),
('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('code',),
'ordering': ['-valid_from', '-valid_to', 'code'],
},
),
migrations.CreateModel(
@ -44,10 +44,11 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('draft', 'Draft'), ('unfulfilled', 'Unfulfilled'), ('partially_fulfilled', 'Partially fulfilled'), ('partially_returned', 'Partially returned'), ('returned', 'Returned'), ('fulfilled', 'Fulfilled'), ('canceled', 'Canceled')], default='unfulfilled', max_length=32)),
('subtotal_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('coupon_amount', models.CharField(blank=True, max_length=255)),
('coupon_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('shipping_total', models.DecimalField(decimal_places=2, default=0, max_digits=5)),
('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('weight', django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True)),
('subscription_description', models.CharField(blank=True, max_length=500)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('billing_address', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')),
@ -56,7 +57,8 @@ class Migration(migrations.Migration):
('shipping_address', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')),
],
options={
'ordering': ('-created_at',),
'ordering': ['-created_at'],
'permissions': [('cancel_order', 'Can cancel order')],
},
),
migrations.CreateModel(
@ -88,31 +90,33 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Product Categories',
},
),
migrations.CreateModel(
name='ProductPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='products/images')),
('sorting', models.PositiveIntegerField(blank=True, null=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.product')),
],
options={
'ordering': ['sorting'],
},
),
migrations.CreateModel(
name='ShippingRate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('shipping_provider', models.CharField(choices=[('USPS', 'USPS')], default='USPS', max_length=255)),
('name', models.CharField(max_length=255)),
('container', models.CharField(choices=[('LG FLAT RATE BOX', 'Flate Rate Box - Large'), ('MD FLAT RATE BOX', 'Flate Rate Box - Medium'), ('REGIONALRATEBOXA', 'Regional Rate Box A'), ('REGIONALRATEBOXB', 'Regional Rate Box B'), ('VARIABLE', 'Variable')], default='VARIABLE', max_length=255)),
('min_order_weight', models.PositiveIntegerField(blank=True, null=True)),
('max_order_weight', models.PositiveIntegerField(blank=True, null=True)),
('container', models.CharField(choices=[('PRIORITY', (('FLAT RATE ENVELOPE', 'Flat Rate Envelope'), ('LEGAL FLAT RATE ENVELOPE', 'Legal Flat Rate Envelope'), ('PADDED FLAT RATE ENVELOPE', 'Padded Flat Rate Envelope'), ('SM FLAT RATE ENVELOPE', 'Sm Flat Rate Envelope'), ('WINDOW FLAT RATE ENVELOPE', 'Window Flat Rate Envelope'), ('GIFT CARD FLAT RATE ENVELOPE', 'Gift Card Flat Rate Envelope'), ('SM FLAT RATE BOX', 'Sm Flat Rate Box'), ('MD FLAT RATE BOX', 'Md Flat Rate Box'), ('LG FLAT RATE BOX', 'Lg Flat Rate Box'), ('VARIABLE', 'Variable'))), ('PRIORITY COMMERCIAL', (('REGIONALRATEBOXA', 'Regional Rate Box A'), ('REGIONALRATEBOXB', 'Regional Rate Box B')))], default='VARIABLE', max_length=255)),
('min_order_weight', django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True)),
('max_order_weight', django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True)),
('is_selectable', models.BooleanField(default=True)),
],
options={
'ordering': ['min_order_weight'],
},
),
migrations.CreateModel(
name='SiteSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('usps_user_id', models.CharField(max_length=255)),
],
options={
'verbose_name': 'Site Settings',
'verbose_name_plural': 'Site Settings',
},
),
migrations.CreateModel(
name='Transaction',
fields=[
@ -141,44 +145,63 @@ class Migration(migrations.Migration):
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_id', models.CharField(blank=True, max_length=255)),
('customer', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscription', to=settings.AUTH_USER_MODEL)),
('stripe_id', models.CharField(blank=True, db_index=True, max_length=255)),
('items', django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(blank=True, null=True), default=list, size=None)),
('metadata', models.JSONField(blank=True, null=True)),
('is_active', models.BooleanField(default=False)),
('total_weight', django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriptions', to=settings.AUTH_USER_MODEL)),
('shipping_address', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address')),
],
),
migrations.CreateModel(
name='SiteSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('usps_user_id', models.CharField(max_length=255)),
('free_shipping_min', models.DecimalField(blank=True, decimal_places=2, help_text='Minimum dollar amount in the cart subtotal to qualify for free shipping.', max_digits=12, null=True)),
('max_cart_quantity', models.PositiveIntegerField(blank=True, default=20, help_text='Maximum amount of items allowed in cart.', null=True)),
('max_cart_weight', django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, help_text='Maximum weight allowed for cart.', measurement=measurement.measures.mass.Mass, null=True)),
('default_contact_email', models.CharField(blank=True, max_length=255)),
('order_from_email', models.CharField(blank=True, max_length=255)),
('default_zip_origination', models.CharField(blank=True, default='98368', max_length=5)),
('default_shipping_rate', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.shippingrate')),
],
options={
'verbose_name': 'Site Settings',
'verbose_name_plural': 'Site Settings',
},
),
migrations.CreateModel(
name='ProductVariant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('sku', models.CharField(max_length=255, unique=True)),
('stripe_id', models.CharField(blank=True, max_length=255)),
('sku', models.CharField(blank=True, max_length=255)),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('weight', django_measurement.models.MeasurementField(blank=True, measurement=measurement.measures.mass.Mass, null=True)),
('weight', django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True)),
('visible_in_listings', models.BooleanField(default=True)),
('track_inventory', models.BooleanField(default=False)),
('stock', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)])),
('sorting', models.PositiveIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.productphoto')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variants', to='core.product')),
],
options={
'ordering': ['weight'],
'ordering': ['sorting', 'weight'],
},
),
migrations.CreateModel(
name='ProductPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='products/images')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.product')),
],
),
migrations.CreateModel(
name='ProductOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('options', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255), size=None)),
('products', models.ManyToManyField(related_name='options', to='core.Product')),
('products', models.ManyToManyField(related_name='options', to='core.product')),
],
),
migrations.AddField(
@ -195,19 +218,23 @@ class Migration(migrations.Migration):
('customer_note', models.TextField(blank=True, default='')),
('currency', models.CharField(default='USD', max_length=3)),
('unit_price', models.DecimalField(decimal_places=2, max_digits=12)),
('tax_rate', models.DecimalField(decimal_places=2, default=Decimal('0.0'), max_digits=5)),
('order', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='core.order')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_lines', to='core.product')),
('variant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_lines', to='core.productvariant')),
],
),
migrations.AddField(
model_name='coupon',
name='products',
field=models.ManyToManyField(blank=True, to='core.Product'),
model_name='order',
name='subscription',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='core.subscription'),
),
migrations.AddField(
model_name='coupon',
name='users',
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
name='variants',
field=models.ManyToManyField(blank=True, to='core.productvariant'),
),
migrations.AddIndex(
model_name='subscription',
index=models.Index(fields=['stripe_id'], name='core_subscr_stripe__08018b_idx'),
),
]

View File

@ -1,4 +1,5 @@
import logging
import json
from decimal import Decimal
from PIL import Image
from measurement.measures import Weight
@ -24,8 +25,7 @@ from . import (
TransactionStatus,
OrderStatus,
ShippingProvider,
ShippingContainer,
build_usps_rate_request
ShippingContainer
)
from .weight import WeightUnits, zero_weight
@ -124,6 +124,7 @@ class Product(models.Model):
class ProductPhoto(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
image = models.ImageField(upload_to='products/images')
sorting = models.PositiveIntegerField(blank=True, null=True)
def __str__(self):
return f'{self.product.name} {self.image}'
@ -134,6 +135,9 @@ class ProductPhoto(models.Model):
super(ProductPhoto, self).delete(*args, **kwargs)
storage.delete(path)
class Meta:
ordering = ['sorting']
# def save(self, *args, **kwargs):
# super().save(*args, **kwargs)
@ -166,7 +170,6 @@ class ProductVariant(models.Model):
)
name = models.CharField(max_length=255)
sku = models.CharField(max_length=255, blank=True)
stripe_id = models.CharField(max_length=255, blank=True)
price = models.DecimalField(
max_digits=settings.DEFAULT_MAX_DIGITS,
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
@ -176,9 +179,11 @@ class ProductVariant(models.Model):
weight = MeasurementField(
measurement=Weight,
unit_choices=WeightUnits.CHOICES,
default=zero_weight,
blank=True,
null=True
)
visible_in_listings = models.BooleanField(default=True)
track_inventory = models.BooleanField(default=False)
stock = models.IntegerField(
blank=True,
@ -240,7 +245,7 @@ class Coupon(models.Model):
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
)
products = models.ManyToManyField(Product, blank=True)
variants = models.ManyToManyField(ProductVariant, blank=True)
users = models.ManyToManyField(User, blank=True)
class Meta:
@ -378,6 +383,14 @@ class Order(models.Model):
blank=True,
null=True
)
subscription = models.ForeignKey(
'Subscription',
related_name='orders',
editable=False,
null=True,
on_delete=models.SET_NULL
)
subscription_description = models.CharField(max_length=500, blank=True)
created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True)
@ -410,7 +423,10 @@ class Order(models.Model):
return reverse('dashboard:order-detail', kwargs={'pk': self.pk})
class Meta:
ordering = ('-created_at',)
ordering = ['-created_at']
permissions = [
('cancel_order', 'Can cancel order'),
]
class Transaction(models.Model):
@ -438,6 +454,13 @@ class OrderLine(models.Model):
editable=False,
on_delete=models.CASCADE
)
product = models.ForeignKey(
Product,
related_name='order_lines',
on_delete=models.SET_NULL,
blank=True,
null=True,
)
variant = models.ForeignKey(
ProductVariant,
related_name='order_lines',
@ -458,9 +481,6 @@ class OrderLine(models.Model):
max_digits=settings.DEFAULT_MAX_DIGITS,
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
)
tax_rate = models.DecimalField(
max_digits=5, decimal_places=2, default=Decimal('0.0')
)
def get_total(self):
return self.unit_price * self.quantity
@ -500,14 +520,109 @@ class TrackingNumber(models.Model):
return self.tracking_id
class SubscriptionManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class Subscription(models.Model):
stripe_id = models.CharField(max_length=255, blank=True)
customer = models.OneToOneField(
stripe_id = models.CharField(max_length=255, blank=True, db_index=True)
customer = models.ForeignKey(
User,
related_name='subscription',
related_name='subscriptions',
on_delete=models.SET_NULL,
null=True
)
shipping_address = models.ForeignKey(
Address,
related_name='+',
editable=False,
null=True,
on_delete=models.SET_NULL
)
items = ArrayField(
models.JSONField(blank=True, null=True),
default=list
)
metadata = models.JSONField(blank=True, null=True)
is_active = models.BooleanField(default=False)
total_weight = MeasurementField(
measurement=Weight,
unit_choices=WeightUnits.CHOICES,
default=zero_weight,
blank=True,
null=True
)
created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True)
def convert_int_to_decimal(self, price):
return Decimal(str(price)[:-2] + '.' + str(price)[-2:])
def format_product(self, data):
return {
'product': Product.objects.get(pk=data['pk']),
'quantity': data['quantity']
}
def deserialize_subscription(self, data):
subscription = {}
for x in data:
if 'Coffee' in x['description']:
subscription['unit_price'] = self.convert_int_to_decimal(x['price']['unit_amount'])
subscription['description'] = x['description']
if 'Shipping' in x['description']:
subscription['shipping_cost'] = self.convert_int_to_decimal(x['amount'])
return subscription
def create_order(self, data_object):
subscription = self.deserialize_subscription(data_object['lines']['data'])
subscription['items'] = map(self.format_product, self.metadata['products_and_quantities'])
subscription['customer_note'] = f"Grind: {self.metadata['grind']}"
order = Order.objects.create(
customer=self.customer,
status=OrderStatus.UNFULFILLED,
shipping_address=self.shipping_address,
subtotal_amount=self.convert_int_to_decimal(
data_object['subtotal']) - subscription['shipping_cost'],
shipping_total=subscription['shipping_cost'],
total_amount=self.convert_int_to_decimal(data_object['total']),
weight=self.total_weight,
subscription=self,
subscription_description=subscription['description']
)
bulk_lines = [OrderLine(
order=order,
product=item['product'],
quantity=item['quantity'],
customer_note=subscription['customer_note'],
unit_price=subscription['unit_price']
) for item in subscription['items']]
OrderLine.objects.bulk_create(bulk_lines)
def format_metadata(self):
metadata = {}
for key, value in self.metadata.items():
if 'products_and_quantities' in key:
metadata[key] = json.dumps(value)
else:
metadata[key] = value
metadata['subscription_pk'] = self.pk
return metadata
def get_absolute_url(self):
return reverse('storefront:subscription-detail', kwargs={'pk': self.pk})
class Meta:
indexes = [
models.Index(fields=['stripe_id'])
]
class SiteSettings(SingletonBase):
@ -540,6 +655,11 @@ class SiteSettings(SingletonBase):
null=True,
help_text='Maximum weight allowed for cart.'
)
default_contact_email = models.CharField(max_length=255, blank=True)
order_from_email = models.CharField(max_length=255, blank=True)
default_zip_origination = models.CharField(
max_length=5, blank=True, default='98368'
)
def __str__(self):
return 'Site Settings'

101
core/shipping.py Normal file
View File

@ -0,0 +1,101 @@
import logging
from decimal import Decimal
from django.conf import settings
from django.db.models import Q
from measurement.measures import Weight
from core.usps import USPSApi
from core.exceptions import USPSPostageError, ShippingAddressError
from core.models import (
ShippingRate,
SiteSettings,
)
from core import (
ShippingService,
ShippingProvider,
ShippingContainer
)
logger = logging.getLogger(__name__)
def get_shipping_container_choices_from_weight(weight):
is_selectable = Q(
is_selectable=True
)
min_weight_matched = Q(
min_order_weight__lte=weight) | Q(
min_order_weight__isnull=True
)
max_weight_matched = Q(
max_order_weight__gte=weight) | Q(
max_order_weight__isnull=True
)
containers = ShippingRate.objects.filter(
is_selectable & min_weight_matched & max_weight_matched
)
return containers
def get_shipping_container_from_choices(choices):
if len(choices) == 0:
return SiteSettings.load().default_shipping_rate.container
return choices[0].container
def get_shipping_cost(total_weight, postal_code):
if not total_weight > Weight(lb=0):
return Decimal('0.00')
container = get_shipping_container_from_choices(
get_shipping_container_choices_from_weight(total_weight)
)
usps_rate_request = build_usps_rate_request(
str(total_weight.lb), container, str(postal_code)
)
usps = USPSApi(SiteSettings.load().usps_user_id, test=settings.DEBUG)
try:
validation = usps.get_rate(usps_rate_request)
except ConnectionError as e:
raise e(
'Could not connect to USPS, try again.'
)
logger.info(validation.result)
try:
postage = dict(
validation.result['RateV4Response']['Package']['Postage']
)
except KeyError:
raise USPSPostageError(
'Could not retrieve postage.'
)
if usps_rate_request['service'] == ShippingContainer.PRIORITY:
shipping_cost = Decimal(postage['Rate'])
elif usps_rate_request['service'] == ShippingContainer.PRIORITY_COMMERCIAL:
shipping_cost = Decimal(postage['CommercialRate'])
return shipping_cost
def build_usps_rate_request(weight, container, zip_destination):
service = ShippingContainer.get_shipping_service_from_container(container)
return \
{
'service': service,
'zip_origination': SiteSettings.load().default_zip_origination,
'zip_destination': zip_destination,
'pounds': weight,
'ounces': '0',
'container': container,
'width': '',
'length': '',
'height': '',
'girth': '',
'machinable': 'TRUE'
}

View File

@ -17,35 +17,17 @@ from .tasks import (
)
logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_API_KEY
# @receiver(post_save, sender=ProductVariant, dispatch_uid='variant_created')
# def variant_saved(sender, instance, created, **kwargs):
# logger.info('Product was saved')
# if created or not instance.stripe_id:
# stripe.api_key = settings.STRIPE_API_KEY
# prod_response = stripe.Product.create(
# name=instance.product.name + ': ' + instance.name,
# description=instance.product.description
# )
# price_response = stripe.Price.create(
# unit_amount=int(instance.price * 100),
# currency=settings.DEFAULT_CURRENCY,
# product=prod_response['id']
# )
# instance.stripe_id = prod_response['id']
# instance.stripe_price_id = price_response['id']
# instance.save()
# else:
# stripe.Product.modify(
# instance.stripe_id,
# name=instance.product.name + ': ' + instance.name,
# description=instance.product.description
# )
# stripe.Price.modify(
# instance.stripe_price_id,
# unit_amount=int(instance.price * 100)
# )
def format_order_lines(lines):
for line in lines:
yield {
'variant': str(line.variant),
'customer_note': line.customer_note,
'quantity': line.quantity,
'unit_price': str(line.unit_price)
}
@receiver(post_save, sender=Order, dispatch_uid="order_created")
@ -65,7 +47,23 @@ def transaction_created(sender, instance, created, **kwargs):
order = {
'order_id': instance.order.pk,
'email': instance.order.customer.email,
'full_name': instance.order.customer.get_full_name()
'full_name': instance.order.customer.get_full_name(),
'subtotal_amount': str(instance.order.subtotal_amount),
'coupon_amount': str(instance.order.coupon_amount),
'shipping_total': str(instance.order.shipping_total),
'total_amount': str(instance.order.total_amount),
'shipping_address': {
'first_name': instance.order.shipping_address.first_name,
'last_name': instance.order.shipping_address.last_name,
'street_address_1': instance.order.shipping_address.street_address_1,
'street_address_2': instance.order.shipping_address.street_address_2,
'city': instance.order.shipping_address.city,
'state': instance.order.shipping_address.state,
'postal_code': instance.order.shipping_address.postal_code
},
'line_items': list(
format_order_lines(instance.order.lines.all())
)
}
send_order_confirmation_email.delay(order)
instance.confirmation_email_sent = True
@ -102,7 +100,9 @@ def order_line_post_save(sender, instance, created, **kwargs):
pk=instance.order.pk
)[0]
order.status = get_order_status(order.total_quantity_fulfilled, order.total_quantity_ordered)
order.status = get_order_status(
order.total_quantity_fulfilled, order.total_quantity_ordered
)
order.save()
# order.update(

View File

@ -0,0 +1,74 @@
import locale
locale.setlocale(locale.LC_ALL, '')
data_object['subscription']
def convert_int_to_currency(price):
return locale.currency(int(price) / 100)
def convert_int_to_decimal(price):
return Decimal(str(price)[:-2] + '.' + str(price)[-2:])
def find_shipping_cost(data):
for x in data:
if x['description'] == 'Shipping':
return convert_int_to_currency(x['amount'])
break
else:
continue
def format_product(data, unit_price):
return {
'product': Product.objects.get(pk=data['pk']),
'quantity': data['quantity']
}
def deserialize_subscription(data):
sub_data = {}
for x in data:
if 'products_and_quantities' in x['metadata']:
sub_data['customer_note'] = f"Grind: {x['metadata']['grind']}"
sub_data['unit_price'] = convert_int_to_decimal(x['price']['unit_amount'])
sub_data['items'] = map(format_product, x['metadata']['products_and_quantities'])
sub_data['total_weight'] = x['metadata']['total_weight']
if x['description'] == 'Shipping':
sub_data['shipping_cost'] = convert_int_to_decimal(x['amount'])
continue
return sub_data
# shipping_cost = find_shipping_cost(data_object['lines']['data'])
# items = find_products(data_object['lines']['data'])
# unit_price = find_unit_price(data_object['lines']['data'])
sub_data = deserialize_subscription(data_object['lines']['data'])
order = Order.objects.create(
customer=,
status=,
billing_address=,
shipping_address=,
subtotal_amount=,
shipping_total=,
total_amount=data_object['total'],
weight=
)
order.lines.add(
[OrderLine(
product=item['product'],
quantity=item['quantity'],
customer_note=sub_data['customer_note'],
unit_price=sub_data['unit_price']
) for item in sub_data['items']]
)
order.save()

View File

@ -1,11 +1,10 @@
from celery import shared_task
from celery.utils.log import get_task_logger
from django.conf import settings
from django.core.mail import EmailMessage, send_mail
from templated_email import send_templated_mail
from .models import Order
from .models import Order, SiteSettings
logger = get_task_logger(__name__)
@ -20,7 +19,7 @@ ORDER_REFUND_TEMPLATE = 'storefront/order_refund'
def send_order_confirmation_email(order):
send_templated_mail(
template_name=CONFIRM_ORDER_TEMPLATE,
from_email=settings.ORDER_FROM_EMAIL,
from_email=SiteSettings.load().order_from_email,
recipient_list=[order['email']],
context=order
)
@ -32,7 +31,7 @@ def send_order_confirmation_email(order):
def send_order_shipped_email(data):
send_templated_mail(
template_name=SHIP_ORDER_TEMPLATE,
from_email=settings.ORDER_FROM_EMAIL,
from_email=SiteSettings.load().order_from_email,
recipient_list=[data['email']],
context=data
)

View File

@ -4,6 +4,9 @@ import xmltodict
from lxml import etree
from usps import USPSApi as USPSApiBase
from django.conf import settings
from . import ShippingContainer
class USPSApi(USPSApiBase):
@ -19,7 +22,6 @@ class USPSApi(USPSApiBase):
class Rate:
def __init__(self, usps, request, **kwargs):
xml = etree.Element('RateV4Request', {'USERID': usps.api_user_id})
etree.SubElement(xml, 'Revision').text = '2'

View File

@ -23,6 +23,7 @@ class ProductVariantUpdateForm(forms.ModelForm):
'sku',
'price',
'weight',
'visible_in_listings',
'track_inventory',
'stock',
'sorting',
@ -48,7 +49,7 @@ class CouponForm(forms.ModelForm):
'valid_to',
'discount_value_type',
'discount_value',
'products',
'variants',
)
widgets = {
'valid_from': forms.DateInput(attrs={
@ -97,7 +98,10 @@ class OrderTrackingForm(forms.ModelForm):
class Meta:
model = TrackingNumber
fields = ('tracking_id',)
fields = ['tracking_id']
labels = {
'tracking_id': 'Tracking Number'
}
OrderTrackingFormset = forms.inlineformset_factory(

View File

@ -0,0 +1,62 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Catalog | {% endblock %}
{% block content %}
<article>
<header class="object-header">
<h1><img src="{% static 'images/cubes.png' %}"> Catalog</h1>
<div class="object-menu">
<a href="{% url 'dashboard:category-create' %}" class="btn">+ New category</a>
<a href="{% url 'dashboard:product-create' %}" class="btn">+ New product</a>
</div>
</header>
{% for category in category_list %}
<section class="panel">
<header class="panel-header">
<h3>{{ category }}</h3>
<a href="{% url 'dashboard:category-detail' category.pk %}" class="btn">View category &rarr;</a>
</header>
{% include 'dashboard/product/_table.html' with product_list=category.product_set.all %}
</section>
{% endfor %}
{% if uncategorized_products|length > 0 %}
<section class="panel">
<header class="panel-header">
<h4>Uncategorized Products</h4>
</header>
{% include 'dashboard/product/_table.html' with product_list=uncategorized_products %}
</section>
{% endif %}
<section class="panel">
<header class="panel-header">
<h4>Product Options</h4>
<a href="{% url 'dashboard:option-create' %}" class="btn">+ New product option</a>
</header>
<table>
<thead>
<tr>
<th>Name</th>
<th>Values</th>
</tr>
</thead>
<tbody>
{% for option in option_list %}
<tr class="is-link" onclick="window.location='{% url 'dashboard:option-detail' option.pk %}'">
<th>{{ option.name }}</th>
<td>
{% for val in option.options %}
{{ val }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,9 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Delete {{ category }} | {% endblock %}
{% block content %}
{% url 'dashboard:category-detail' category.pk as back_url %}
{% include 'dashboard/partials/_delete_form.html' with back_url=back_url object=category form=form %}
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}New category | {% endblock %}
{% block content %}
{% url 'dashboard:catalog' as back_url %}
{% include 'dashboard/partials/_create_form.html' with back_url=back_url object='category' form=form %}
{% endblock %}

View File

@ -0,0 +1,32 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}{{ category }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:catalog' %}">&larr; Back to catalog</a>
</p>
<header class="object-header">
<div>
<h1><img src="{% static 'images/cubes.png' %}"> {{ category.name }}</h1>
<p><strong>Is a main category</strong>: {{ category.main_category|yesno:"Yes,No" }}</p>
</div>
<div class="object-menu">
{% if perms.core.delete_productcategory %}
<a href="{% url 'dashboard:category-delete' category.pk %}" class="btn btn-warning">Delete</a>
{% endif %}
{% if perms.core.change_productcategory %}
<a href="{% url 'dashboard:category-update' category.pk %}" class="btn">Edit</a>
{% endif %}
</div>
</header>
<section class="panel">
<header class="panel-header">
<h4>Products</h4>
</header>
{% include 'dashboard/product/_table.html' with product_list=category.product_set.all %}
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}Update {{ category }} | {% endblock %}
{% block content %}
{% url 'dashboard:category-detail' category.pk as back_url %}
{% include 'dashboard/partials/_form.html' with back_url=back_url object=category form=form %}
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Categories | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:catalog' %}">&larr; Back to catalog</a>
</p>
<header class="object-header">
<h1><img src="{% static 'images/cubes.png' %}"> Categories</h1>
<div class="object-menu">
<a href="{% url 'dashboard:category-create' %}" class="btn">+ New category</a>
</div>
</header>
{% for category in category_list %}
<section class="panel">
<header class="panel-header">
<h3>{{ category }}</h3>
<a href="{% url 'dashboard:category-detail' category.pk %}" class="btn">View category &rarr;</a>
</header>
{% include 'dashboard/product/_table.html' with product_list=category.product_set.all %}
</section>
{% endfor %}
</article>
{% endblock content %}

View File

@ -0,0 +1,67 @@
{% extends 'dashboard.html' %}
{% load static %}
{% load tz %}
{% block head_title %}Site Configuration | {% endblock %}
{% block content %}
<article>
<header class="object-header">
<h1><img src="{% static 'images/gear.png' %}"> Site configuration</h1>
</header>
<section class="panel">
<header class="panel-header">
<h4>Site Settings</h4>
{% if perms.core.change_sitesettings %}
<a href="{% url 'dashboard:settings-update' site_settings.pk %}" class="btn">Edit</a>
{% endif %}
</header>
<dl class="panel-datalist">
<dt>USPS User ID</dt>
<dd>{{ site_settings.usps_user_id }}</dd>
<dt>Default shipping rate</dt>
<dd>{{ site_settings.default_shipping_rate }}</dd>
<dt>Free shipping min</dt>
<dd>${{ site_settings.free_shipping_min }}</dd>
<dt>Max cart quantity</dt>
<dd>{{ site_settings.max_cart_quantity }} items</dd>
<dt>Max cart weight</dt>
<dd>{{ site_settings.max_cart_weight }}</dd>
<dt>Default contact email</dt>
<dd>{{ site_settings.default_contact_email }}</dd>
<dt>Default order-from email</dt>
<dd>{{ site_settings.order_from_email }}</dd>
</dl>
</section>
<section class="panel">
<header class="panel-header">
<h4>Shipping rates</h4>
<a href="{% url 'dashboard:rate-create' %}" class="btn">+ New rate</a>
</header>
<table>
<thead>
<tr>
<th>Name</th>
<th>Shipping Provider</th>
<th>Container</th>
<th>Weight range</th>
</tr>
</thead>
<tbody>
{% for rate in shipping_rate_list %}
<tr class="is-link" onclick="window.location='{% url 'dashboard:rate-detail' rate.pk %}'">
<td>{{ rate }}</td>
<td>{{ rate.shipping_provider }}</td>
<td>{{ rate.get_container_display }}</td>
<td>{{ rate.min_order_weight }} &ndash; {{ rate.max_order_weight }}</td>
</tr>
{% empty %}
<p>No shipping rates yet.</p>
{% endfor %}
</tbody>
</table>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Delete {{ coupon }} | {% endblock %}
{% block content %}
{% url 'dashboard:coupon-detail' coupon.pk as back_url %}
{% include 'dashboard/partials/_delete_form.html' with back_url=back_url object=coupon form=form %}
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}New coupon | {% endblock %}
{% block content %}
{% url 'dashboard:coupon-list' as back_url %}
{% include 'dashboard/partials/_create_form.html' with back_url=back_url object='coupon' form=form %}
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}{{ coupon }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:coupon-list' %}">&larr; Back to coupons</a>
</p>
<header class="object-header">
<h1><img src="{% static 'images/coupon.png' %}"> {{ coupon.name }}</h1>
<div class="object-menu">
{% if perms.core.delete_coupon %}
<a href="{% url 'dashboard:coupon-delete' coupon.pk %}" class="btn btn-warning">Delete</a>
{% endif %}
{% if perms.core.change_coupon %}
<a href="{% url 'dashboard:coupon-update' coupon.pk %}" class="btn">Edit</a>
{% endif %}
</div>
</header>
<section class="panel">
<header class="panel-header">
<h4>Details</h4>
</header>
<dl class="panel-datalist">
<dt>Applies to</dt>
<dd>{{ coupon.get_type_display }}</dd>
<dt>Code</dt>
<dd>{{ coupon.code }}</dd>
<dt>Valid from</dt>
<dd>{{ coupon.valid_from }}</dd>
<dt>Valid to</dt>
<dd>{{ coupon.valid_to }}</dd>
<dt>Discount value</dt>
<dd>{{ coupon.discount_value }} {{ coupon.get_discount_value_type_display }}</dd>
</dl>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}Update {{ coupon }} | {% endblock %}
{% block content %}
{% url 'dashboard:coupon-detail' coupon.pk as back_url %}
{% include 'dashboard/partials/_form.html' with back_url=back_url object=coupon form=form %}
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Coupons | {% endblock %}
{% block content %}
<article>
<header class="object-header">
<h1><img src="{% static 'images/coupon.png' %}"> Coupons</h1>
<a href="{% url 'dashboard:coupon-create' %}" class="btn">+ New coupon</a>
</header>
<section class="panel">
<header class="panel-header">
<h4></h4>
</header>
<table>
<thead>
<th>Name</th>
<th>Code</th>
<th>Valid</th>
<th>Starts</th>
<th>Ends</th>
<th>Value</th>
</thead>
<tbody>
{% for coupon in coupon_list %}
<tr class="is-link" onclick="window.location='{% url 'dashboard:coupon-detail' coupon.pk %}'">
<td>{{ coupon.name }}</td>
<td>{{ coupon.code }}</td>
<td>
<div class="status-display">
{% if coupon.is_valid %}
<span class="status-dot status-success"></span> Valid
{% else %}
<span class="status-dot status-info"></span> Invalid
{% endif %}
</div>
</td>
<td>{{ coupon.valid_from|date:"SHORT_DATE_FORMAT" }}</td>
<td>{{ coupon.valid_to|date:"SHORT_DATE_FORMAT" }}</td>
<td>{{ coupon.discount_value }} {{ coupon.get_discount_value_type_display }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5">No coupons.</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,50 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}{{ customer.get_full_name }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:customer-list' %}">&larr; Back to customers</a>
</p>
<header class="object-header">
<h1><img src="{% static "images/customer.png" %}"> Customer: {{customer.get_full_name}}</h1>
{% if perms.core.change_customer %}
<a href="{% url 'dashboard:customer-update' customer.pk %}" class="btn">Edit</a>
{% endif %}
</header>
<section class="panel">
<header class="panel-header">
<h4>Details</h4>
</header>
<dl class="panel-datalist">
<dt>Primary email address</dt>
<dd>
<a href="mailto:{{ customer.email }}">{{ customer.email }} &nearr;</a>
</dd>
<dt>Default shipping address</dt>
<dd>
{% include 'dashboard/partials/_address.html' with address=customer.default_shipping_address %}
</dd>
<dt>All addresses</dt>
<dd>
{% for address in customer.addresses.all %}
{% include 'dashboard/partials/_address.html' with address=address %}
{% endfor %}
</dd>
</dl>
</section>
<section class="panel">
<header class="panel-header">
<h4>Orders</h4>
</header>
<table>
{% include 'dashboard/order/_table.html' with order_list=customer.orders.all %}
</table>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}Update {{ customer.get_full_name }} | {% endblock %}
{% block content %}
{% url 'dashboard:customer-detail' customer.pk as back_url %}
{% include 'dashboard/partials/_form.html' with back_url=back_url object=customer form=form %}
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Customers | {% endblock %}
{% block content %}
<article>
<header class="object-header">
<h1><img src="{% static 'images/customer.png' %}"> Customers</h1>
</header>
<section class="panel">
<header class="panel-header">
<h4></h4>
</header>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Orders</th>
</tr>
</thead>
<tbody>
{% for customer in user_list %}
<tr class="is-link" onclick="window.location='{% url 'dashboard:customer-detail' customer.pk %}'">
<td>{{customer.get_full_name}}</td>
<td>{{customer.email}}</td>
<td>{{customer.num_orders}}</td>
</tr>
{% empty %}
<tr>
<td colspan="3">No customers</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td>
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</td>
</tr>
</tfoot>
</table>
</section>
</article>
{% endblock content %}

View File

@ -1,11 +1,13 @@
{% extends "dashboard.html" %}
{% extends 'dashboard.html' %}
{% load static %}
{% load tz %}
{% block head_title %}Home | {% endblock %}
{% block content %}
<article>
<h1><img src="{% static "images/store.png" %}" alt=""> Port Townsend Coffee</h1>
<section class="store__info">
<h1><img src="{% static "images/store.png" %}"> Port Townsend Roasting Co.</h1>
<section class="store-info">
<div class="orders">
<h5>Orders</h5>
<small>Today {% now "" %}</small>
@ -19,7 +21,7 @@
</section>
<section>
<a class="store__action" href="{% url 'dashboard:order-list' %}?status=unfulfilled">{{orders_unfulfilled}} orders ready to fulfill &rarr;</a>
<a class="store-action" href="{% url 'dashboard:order-list' %}?status=unfulfilled">{{ orders_unfulfilled }} orders ready to fulfill &rarr;</a>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Delete {{ option }} | {% endblock %}
{% block content %}
{% url 'dashboard:option-detail' option.pk as back_url %}
{% include 'dashboard/partials/_delete_form.html' with back_url=back_url object=option form=form %}
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}New option | {% endblock %}
{% block content %}
{% url 'dashboard:catalog' as back_url %}
{% include 'dashboard/partials/_create_form.html' with back_url=back_url object='option' form=form %}
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}{{ option }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:catalog' %}">&larr; Back to catalog</a>
</p>
<header class="object-header">
<h1>Product Option: {{ option.name }}</h1>
<div class="object-menu">
{% if perms.core.delete_productoption %}
<a href="{% url 'dashboard:option-delete' option.pk %}" class="btn btn-warning">Delete</a>
{% endif %}
{% if perms.core.change_productoption %}
<a href="{% url 'dashboard:option-update' option.pk %}" class="btn">Edit</a>
{% endif %}
</div>
</header>
<section class="panel">
<header class="panel-header">
<h4>Products</h4>
</header>
<ul>
{% for product in option.products.all %}
<li><a href="{% url 'dashboard:product-detail' product.pk %}">{{ product.name }}</a></li>
{% endfor %}
</ul>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}Update {{ option }} | {% endblock %}
{% block content %}
{% url 'dashboard:option-detail' option.pk as back_url %}
{% include 'dashboard/partials/_form.html' with back_url=back_url object=option form=form %}
{% endblock %}

View File

@ -0,0 +1,24 @@
<thead>
<tr>
<th>Order No.</th>
<th>Date</th>
<th>Customer</th>
<th>Status</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for order in order_list %}
<tr class="is-link" onclick="window.location='{% url 'dashboard:order-detail' order.pk %}'">
<td>No. {{order.pk}} {% if order.subscription %}(subscription){% endif %}</td>
<td>{{order.created_at|date:"D, M j Y"}}</td>
<td>{{order.customer.get_full_name}}</td>
<td>
<div class="status-display">
<span class="status-dot status-{{order.status}}"></span> {{order.get_status_display}}
</div>
</td>
<td>${{order.total_amount}}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,26 @@
{% extends 'dashboard.html' %}
{% block head_title %}Cancel Order No. {{ order.pk }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:order-detail' order.pk %}">&larr; Back</a>
</p>
<header>
<h1>Cancel Order No. {{order.pk}}</h1>
</header>
<section class="panel">
<header class="panel-header">
<h4>Are you sure you want to cancel Order No. {{ order.pk }}?</h4>
</header>
<form method="POST" class="panel-form">
{% csrf_token %}
{{ form.as_p }}
<p>
<input class="btn btn-warning" type="submit" value="Cancel order">
</p>
</form>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,163 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Order No. {{ order.pk }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:order-list' %}">&larr; Back to orders</a>
</p>
<header class="object-header">
<h1><img src="{% static 'images/box.png' %}"> Order No. {{order.pk}}</h1>
{% if perms.core.cancel_order and order.status != 'canceled' %}
<a class="btn btn-warning" href="{% url 'dashboard:order-cancel' order.pk %}">Cancel order</a>
{% endif %}
</header>
<section class="panel">
<header class="panel-header">
<h4>Details</h4>
</header>
<dl class="panel-datalist">
<dt>Date</dt>
<dd>{{ order.created_at }}</dd>
<dt>Customer</dt>
<dd>
<a href="{% url 'dashboard:customer-detail' order.customer.pk %}">{{order.customer.get_full_name}}</a>&emsp;
<a href="mailto:{{order.customer.email}}">{{order.customer.email}} &nearr;</a>
</dd>
{% if order.subscription %}
<dt>Subscription</dt>
<dd>
{{ order.subscription_description }}&emsp;<a href="https://dashboard.stripe.com/subscriptions/{{ order.subscription.stripe_id }}" target="_blank">View on Stripe &nearr;</a>
</dd>
{% else %}
<dt>PayPal Transaction</dt>
<dd>
{{order.transaction.get_status_display}}&emsp;<a href="https://www.paypal.com/activity/payment/{{ order.transaction.paypal_id }}" target="_blank">View on PayPal &nearr;</a>
</dd>
{% endif %}
<dt>Status</dt>
<dd>
<span class="status status-{{order.status}}">{{order.get_status_display}} ({{order.total_quantity_fulfilled}} / {{order.total_quantity_ordered}})</span>
</dd>
</dl>
</section>
<section class="panel">
<header class="panel-header">
<h4>Items</h4>
<a href="{% url 'dashboard:order-fulfill' order.pk %}" class="btn">Fulfill order &rarr;</a>
</header>
<table>
<thead>
<tr>
<th>Product</th>
<th>SKU</th>
<th>Quantity</th>
<th>Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for line in order.lines.all %}
<tr>
{% if line.variant %}
{% with product=line.variant.product %}
<td>
<figure class="product-figure">
{% if line.variant.image %}
<img class="product-image" src="{{line.variant.image.image.url}}" alt="{{line.variant.image.image}}">
{% else %}
<img class="product-image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
<figcaption><strong>{{line.variant}}</strong><br>{{line.customer_note}}</figcaption>
</figure>
</td>
<td>{{product.sku}}</td>
<td>{{line.quantity}}</td>
<td>${{line.unit_price}}</td>
<td>${{line.get_total}}</td>
{% endwith %}
{% elif line.product %}
{% with product=line.product %}
<td>
<figure class="product-figure">
{% if line.variant.image %}
<img class="product-image" src="{{line.variant.image.image.url}}" alt="{{line.variant.image.image}}">
{% else %}
<img class="product-image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
<figcaption><strong>{{line.product}}</strong><br>{{line.customer_note}}</figcaption>
</figure>
</td>
<td>{{product.sku}}</td>
<td>{{line.quantity}}</td>
<td>${{line.unit_price}}</td>
<td>${{line.get_total}}</td>
{% endwith %}
{% endif %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<th colspan="4">Subtotal:</th>
<td>${{order.subtotal_amount}}</td>
</tr>
{% if order.coupon_amount > 0 %}
<tr>
<th colspan="4">Discount:</th>
<td>${{ order.coupon_amount }}</td>
</tr>
{% endif %}
<tr>
<th colspan="4">Shipping:</th>
<td>${{ order.shipping_total }}</td>
</tr>
<tr>
<th colspan="4">Total:</th>
<td>${{ order.total_amount }}</td>
</tr>
</tfoot>
</table>
</section>
<section class="panel">
<header class="panel-header">
<h4>Shipping</h4>
<a href="{% url 'dashboard:order-ship' order.pk %}" class="btn">Add tracking</a>
</header>
<div class="panel-section panel-shipping">
<div>
<strong>Shipping address</strong>
{% include 'dashboard/partials/_address.html' with address=order.shipping_address %}
</div>
<table>
<thead>
<tr>
<th>Date</th>
<th>Tracking Number</th>
</tr>
</thead>
<tbody>
{% for number in order.tracking_numbers.all %}
<tr>
<td>{{number.created_at|date:"SHORT_DATE_FORMAT" }}</td>
<td>
<a href="https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1={{ number.tracking_id }}" target="_blank">{{number.tracking_id}} &nearr;</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2">No tracking information.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,90 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Fulfill Order No. {{ order.pk }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:order-detail' order.pk %}">&larr; Back to order</a>
</p>
<header class="object-header">
<h1><img src="{% static 'images/box.png' %}"> Order No. {{order.pk}}</h1>
</header>
<section class="panel">
<header class="panel-header">
<h3>Fulfillment</h3>
</header>
<form method="POST">
{% csrf_token %}
{{ form.management_form }}
<table>
<thead>
<tr>
<th>Product</th>
<th>SKU</th>
<th>Options</th>
<th>Quantity to fulfill</th>
</tr>
</thead>
<tbody>
{% for form in form %}
<tr>
{% if form.instance.variant %}
{% with product=form.instance.variant.product %}
{{form.id}}
<td>
<figure class="product-figure">
{% if item.variant.image %}
<img class="product-image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img class="product-image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
<figcaption><strong>{{form.instance.variant}}</strong></figcaption>
</figure>
</td>
<td>{{product.sku}}</td>
<td>{{form.instance.customer_note}}</td>
<td>{{form.quantity_fulfilled}} / {{form.instance.quantity}}</td>
{% endwith %}
{% elif form.instance.product %}
{% with product=form.instance.product %}
{{form.id}}
<td>
<figure class="product-figure">
{% if item.variant.image %}
<img class="product-image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img class="product-image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
<figcaption><strong>{{form.instance.product}}</strong></figcaption>
</figure>
</td>
<td>{{product.sku}}</td>
<td>{{form.instance.customer_note}}</td>
<td>{{form.quantity_fulfilled}} / {{form.instance.quantity}}</td>
{% endwith %}
{% endif %}
</tr>
{% endfor %}
</tbody>
<tfoot>
{% for dict in form.errors %}
<tr>
{% for error in dict.values %}
<td>{{ error }}</td>
{% endfor %}
</tr>
{% endfor %}
<tr>
<td colspan="3"></td>
<td class="text-right">
<input class="btn" type="submit" value="Fulfill order">
</td>
</tr>
</tfoot>
</table>
</form>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,41 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Orders | {% endblock %}
{% block content %}
<article>
<header>
<h1><img src="{% static "images/box.png" %}"> Orders</h1>
</header>
<section class="panel">
<header class="panel-header">
<h3></h3>
</header>
<table>
{% include 'dashboard/order/_table.html' with order_list=order_list %}
<tfoot>
<tr>
<td>
<div class="pagination">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ page_obj.previous_page_number }}"> previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next </a>
<a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</div>
</td>
</tr>
</tfoot>
</table>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,49 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Ship Order No. {{ order.pk }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:order-detail' order.pk %}">&larr; Back to order</a>
</p>
<header class="object-header">
<h1><img src="{% static 'images/box.png' %}"> Order No. {{order.pk}}</h1>
</header>
<section class="panel">
<header class="panel-header">
<h3>Add Tracking</h3>
</header>
<div class="panel-section">
<strong>Shipping address</strong>
{% include 'dashboard/partials/_address.html' with address=order.shipping_address %}
</div>
<form method="POST">
{% csrf_token %}
{{ form.management_form }}
<table>
<tbody>
{% for formitem in form %}
{{formitem}}
{% endfor %}
</tbody>
<tfoot>
{% for dict in form.errors %}
<tr>
{% for error in dict.values %}
<td>{{ error }}</td>
{% endfor %}
</tr>
{% endfor %}
<tr>
<td colspan="2" class="text-right">
<button type="submit" class="btn">Ship order and send tracking info to customer <img src="{% static 'images/paper_plane.png' %}" class="inline-image"></button>
</td>
</tr>
</tfoot>
</table>
</form>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,9 @@
<address>
{{address.first_name}}
{{address.last_name}}<br>
{{address.street_address_1}}<br>
{% if address.street_address_2 %}
{{address.street_address_2}}<br>
{% endif %}
{{address.city}}, {{address.state}}, {{address.postal_code}}
</address>

View File

@ -0,0 +1,17 @@
<article>
<p>
<a href="{{ back_url }}">&larr; Back</a>
</p>
<header>
<h1>New {{ object }}</h1>
</header>
<section class="panel">
<form method="POST" class="panel-form">
{% csrf_token %}
{{form.as_p}}
<p>
<button type="submit">Create {{ object }}</button>
</p>
</form>
</section>
</article>

View File

@ -0,0 +1,20 @@
<article>
<p>
<a href="{{ back_url }}">&larr; Back</a>
</p>
<header>
<h1>Delete: {{ object }}</h1>
</header>
<section class="panel">
<header class="panel-header">
<h4>Are you sure you want to delete "{{ object }}"?</h4>
</header>
<form method="POST" class="panel-form">
{% csrf_token %}
{{ form.as_p }}
<p>
<button class="btn-warning" type="submit">Confirm</button>
</p>
</form>
</section>
</article>

View File

@ -0,0 +1,17 @@
<article>
<p>
<a href="{{ back_url }}">&larr; Back</a>
</p>
<header>
<h1>Update {{ object }}</h1>
</header>
<section class="panel">
<form method="POST" class="panel-form">
{% csrf_token %}
{{form.as_p}}
<p>
<button type="submit">Save changes</button>
</p>
</form>
</section>
</article>

View File

@ -0,0 +1,35 @@
<table>
<thead>
<tr>
<th>Sorting</th>
<th colspan="2">Product</th>
<th>Visible in listings</th>
</tr>
</thead>
<tbody>
{% for product in product_list %}
<tr class="is-link" onclick="window.location='{% url 'dashboard:product-detail' product.pk %}'">
<td>{{ product.sorting }}</td>
<td>
<figure class="product-figure">
<img class="product-image" src="{{ product.get_first_img.image.url }}" alt="{{ product.get_first_img.image }}">
</figure>
</td>
<td>
<h4>{{ product.name }}</h4>
</td>
<td>
<div class="status-display">
{% if product.visible_in_listings %}
<span class="status-dot status-success"></span> Visible in listings
{% else %}
<span class="status-dot status-info"></span> Not visible in listings
{% endif %}
</div>
</td>
<td>
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -0,0 +1,9 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Delete {{ product }} | {% endblock %}
{% block content %}
{% url 'dashboard:product-detail' product.pk as back_url %}
{% include 'dashboard/partials/_delete_form.html' with back_url=back_url object=product form=form %}
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}New product | {% endblock %}
{% block content %}
{% url 'dashboard:catalog' as back_url %}
{% include 'dashboard/partials/_create_form.html' with back_url=back_url object='product' form=form %}
{% endblock %}

View File

@ -0,0 +1,142 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}{{ product }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:catalog' %}">&larr; Back to catalog</a>
</p>
<header class="object-header">
<h1><img src="{% static "images/cubes.png" %}"> {{product.name}}</h1>
<div class="object-menu">
{% if perms.core.delete_product %}
<a href="{% url 'dashboard:product-delete' product.pk %}" class="btn btn-warning">Delete</a>
{% endif %}
{% if perms.core.change_product %}
<a href="{% url 'dashboard:product-update' product.pk %}" class="btn">Edit</a>
{% endif %}
</div>
</header>
<section class="panel">
<header class="panel-header">
<h4>Details</h4>
</header>
<dl class="panel-datalist">
<dt>Category</dt>
<dd>{{ product.category }}</dd>
<dt>Ordering</dt>
<dd>{{ product.sorting }}</dd>
<dt>Subtitle</dt>
<dd><h5>{{ product.subtitle }}</h5></dd>
<dt>Description</dt>
<dd>{{ product.description }}</dd>
<dt>Checkout limit</dt>
<dd><strong>{{ product.checkout_limit }}</strong></dd>
<dt>Visibility</dt>
<dd>
{% if product.visible_in_listings %}
<span class="status status-success">Visible in listings</span>
{% else %}
<span class="status status-info">Not visible in listings</span>
{% endif %}
</dd>
<dt>Applied options</dt>
<dd>
<p><em>Edit product options on the <a href="{% url 'dashboard:catalog' %}">catalog page</a></em></p>
{% for option in product.options.all %}
<h3>{{ option.name }}</h3>
<p>
{% for val in option.options %}
{{ val }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
{% endfor %}
</dd>
</dl>
</section>
<section class="panel">
<header class="panel-header">
<h4>Varaints</h4>
<a href="{% url 'dashboard:variant-create' product.pk %}" class="btn">+ New variant</a>
</header>
<table>
<thead>
<tr>
<th>Sorting</th>
<th>Name</th>
<th>Visibility</th>
<th>SKU</th>
<th>Price</th>
<th>Weight</th>
<th colspan="2">Stock</th>
</tr>
</thead>
<tbody>
{% for variant in product.variants.all %}
<tr>
<td>{{ variant.sorting }}</td>
<td>
<h3>{{ variant.name }}</h3>
</td>
<td>
<div class="status-display">
{% if product.visible_in_listings and variant.visible_in_listings %}
<span class="status-dot status-success"></span>Visible in listings
{% elif not product.visible_in_listings and variant.visible_in_listings %}
<span class="status-dot status-warning"></span>Not visible because product
{% else %}
<span class="status-dot status-info"></span>Not visible in listings
{% endif %}
</div>
</td>
<td>{{ variant.sku }}</td>
<td>${{ variant.price }}</td>
<td>{{ variant.weight }}</td>
<td>
{% if variant.track_inventory %}
{{ variant.stock }}
{% else %}
N/A
{% endif %}
</td>
<td>
{% if perms.core.change_productvariant %}
<a href="{% url 'dashboard:variant-update' product.pk variant.pk %}">Edit</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<header class="panel-header">
<h4>Photos</h4>
<a href="{% url 'dashboard:prodphoto-create' product.pk %}" class="btn">+ Upload new photo</a>
</header>
<div class="gallery panel-section">
{% for photo in product.productphoto_set.all %}
<figure class="gallery-item">
<img src="{{ photo.image.url }}">
<figcaption>
<form action="{% url 'dashboard:prodphoto-delete' product.pk photo.pk %}" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-warning" value="Delete photo">
</form>
</figcaption>
</figure>
{% endfor %}
</div>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}Update {{ product }} | {% endblock %}
{% block content %}
{% url 'dashboard:product-detail' product.pk as back_url %}
{% include 'dashboard/partials/_form.html' with back_url=back_url object=product form=form %}
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Products | {% endblock %}
{% block content %}
<article>
<header class="object-header">
<h1><img src="{% static 'images/cubes.png' %}"> Catalog</h1>
<div class="header-menu">
<a href="{% url 'dashboard:category-create' %}" class="btn">+ New category</a>
<a href="{% url 'dashboard:product-create' %}" class="btn">+ New product</a>
</div>
</header>
<section class="panel">
<header class="panel-header">
<h4>Products</h4>
</header>
{% include 'dashboard/product/_table.html' with product_list=product_list %}
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,23 @@
{% extends 'dashboard.html' %}
{% block head_title %}New Product Photo | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:product-detail' product.pk %}">&larr; Back to product</a>
</p>
<header>
<h1>Add photo to {{product.name}}</h1>
</header>
<section class="panel">
<form enctype="multipart/form-data" method="POST" class="panel-form">
{% csrf_token %}
{{form.as_p}}
<p>
<input class="btn" type="submit" value="Add photo">
</p>
</form>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}Delete {{ rate }} | {% endblock %}
{% block content %}
{% url 'dashboard:rate-detail' rate.pk as back_url %}
{% include 'dashboard/partials/_delete_form.html' with back_url=back_url object=rate form=form %}
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}New Shipping Rate | {% endblock %}
{% block content %}
{% url 'dashboard:config' as back_url %}
{% include 'dashboard/partials/_create_form.html' with back_url=back_url object='rate' form=form %}
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends 'dashboard.html' %}
{% load static %}
{% block head_title %}{{ rate }} | {% endblock %}
{% block content %}
<article>
<p>
<a href="{% url 'dashboard:config' %}">&larr; Back to site configuration</a>
</p>
<header class="object-header">
<h1><img src="{% static 'images/gear.png' %}"> Shipping Rate</h1>
<div class="object-menu">
{% if perms.core.delete_shippingrate %}
<a href="{% url 'dashboard:rate-delete' rate.pk %}" class="btn btn-warning">Delete</a>
{% endif %}
{% if perms.core.change_shippingrate %}
<a href="{% url 'dashboard:rate-update' rate.pk %}" class="btn">Edit</a>
{% endif %}
</div>
</header>
<section class="panel">
<header class="panel-header">
<h4>{{rate.name}}</h4>
</header>
<dl class="panel-datalist">
<dt>Shipping Provider</dt>
<dd>{{ rate.shipping_provider }}</dd>
<dt>Container</dt>
<dd>{{ rate.get_container_display }}</dd>
<dt>Weight range</dt>
<dd>{{ rate.min_order_weight }} &ndash; {{ rate.max_order_weight }}</dd>
</dl>
</section>
</article>
{% endblock content %}

View File

@ -0,0 +1,8 @@
{% extends 'dashboard.html' %}
{% block head_title %}Update {{ rate }} | {% endblock %}
{% block content %}
{% url 'dashboard:rate-detail' rate.pk as back_url %}
{% include 'dashboard/partials/_form.html' with back_url=back_url object=rate form=form %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More