Compare commits

..

19 commits

Author SHA1 Message Date
Nico Schottelius
56a3572680 Extend billing to a have a preiod 2025-06-17 14:03:09 +09:00
Nico Schottelius
0d88ef7f32 aider gitignore
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2025-06-17 13:46:39 +09:00
Nico Schottelius
f6e568c67c Add Customer model and billing system with IPv6 billing numbers
- Add Customer model with contact and address information
- Update Order model to reference Customer instead of User
- Add Bill and BillLineItem models for billing functionality
- Add UncloudConfiguration for IPv6 prefix management
- Bills use IPv6 addresses as billing numbers with configurable prefix
- Add billing service functions for creating bills and calculating prices
- Add CreateBillForm for staff to create bills
- Update admin interface for all new models
- Add sample customer creation to test-data command
- Bills automatically set period_start/end to beginning/end of billing_date
2025-06-17 13:46:16 +09:00
Nico Schottelius
11c7e7be51 uncloud_v3: refactor bootstrap 2025-06-09 13:51:22 +09:00
Nico Schottelius
393dc3fd75 [uncloud_v3] update python, django and image location 2025-06-05 21:10:40 +09:00
Nico Schottelius
9dc207ea4c prevent slug = product for product and resource 2025-03-08 12:31:57 +09:00
Nico Schottelius
f881908b74 uncloud_v3 old changes 2025-03-08 11:04:48 +09:00
Nico Schottelius
34a934a90b update from old stuff 2025-03-08 11:04:46 +09:00
Nico Schottelius
ef4ca9d879 [uncloud_v3] update to django 5.0.2 2024-02-25 16:30:05 +09:00
Nico Schottelius
6fa10ef6d5 very old rename? 2023-11-25 22:34:47 +01:00
Nico Schottelius
95dfe62858 upgrade to python 3.11.1 2023-02-05 21:13:30 +01:00
Nico Schottelius
05eea37349 Cleanup and more demo products 2022-07-16 18:22:52 +02:00
51851910c6 Merge pull request '35/order-confirmation-page' (#41) from 35/order-confirmation-page into master
Reviewed-on: uncloud/uncloud#41
2022-03-04 20:04:31 +00:00
86cb43a3c1 Merge pull request 'Do not allow to choose resource unit when min == max' (#40) from 38/restrict-selection into master
Reviewed-on: uncloud/uncloud#40
2022-03-04 20:03:29 +00:00
93013c8997 Merge pull request 'Update system dependency on libldap24 for archlinux' (#39) from mravi/uncloud-mravi:doc into master
Reviewed-on: uncloud/uncloud#39
2022-03-04 20:00:11 +00:00
PCoder
1648355fe7 Add order_confirmation template 2022-02-22 13:33:45 +05:30
PCoder
c50d688171 Add order-confirmation view and take user to this after the purchase 2022-02-22 13:33:29 +05:30
PCoder
72f47dec7c Return product_order object after creation 2022-02-22 13:32:31 +05:30
PCoder
8668e173b9 Do not allow to choose resource unit when min == max 2022-02-22 12:02:43 +05:30
39 changed files with 957 additions and 269 deletions

1
.gitignore vendored
View file

@ -27,3 +27,4 @@ dist/
*.sqlite3
.DS_Store
static/CACHE/
.aider*

View file

@ -1,71 +1,3 @@
# Uncloud
## README
Cloud management platform, the ungleich way.
[![pipeline status](https://code.ungleich.ch/uncloud/uncloud/badges/master/pipeline.svg)](https://code.ungleich.ch/uncloud/uncloud/commits/master)
[![coverage report](https://code.ungleich.ch/uncloud/uncloud/badges/master/coverage.svg)](https://code.ungleich.ch/uncloud/uncloud/commits/master)
## Useful commands
* `./manage.py import-vat-rates path/to/csv`
* `./manage.py createsuperuser`
## Development setup
Install system dependencies:
* On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium`
* sudo apt-get install libpq-dev python-dev libxml2-dev libxslt1-dev libldap2-dev libsasl2-dev libffi-dev
* On Archlinux, [libldap24](https://aur.archlinux.org/packages/libldap24) is needed
NOTE: you will need to configure a LDAP server and credentials for authentication. See `uncloud/settings.py`.
```
# Initialize virtualenv.
» virtualenv .venv
Using base prefix '/usr'
New python executable in /home/fnux/Workspace/ungleich/uncloud/uncloud/.venv/bin/python3
Also creating executable in /home/fnux/Workspace/ungleich/uncloud/uncloud/.venv/bin/python
Installing setuptools, pip, wheel...
done.
# Enter virtualenv.
» source .venv/bin/activate
# Install dependencies.
» pip install -r requirements.txt
[...]
# Run migrations.
» ./manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, opennebula, sessions, uncloud_auth, uncloud_net, uncloud_pay, uncloud_service, uncloud_vm
Running migrations:
[...]
# Run webserver.
» ./manage.py runserver
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
May 07, 2020 - 10:17:08
Django version 3.0.6, using settings 'uncloud.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
```
### Run Background Job Queue
We use Django Q to handle the asynchronous code and Background Cron jobs
To start the workers make sure first that Redis or the Django Q broker is working and you can edit it's settings in the settings file.
```
./manage.py qcluster
```
### Note on PGSQL
If you want to use Postgres:
* Install on configure PGSQL on your base system.
* OR use a container! `podman run --rm -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -it postgres:latest`
Ignore most of it, checkout uncloud_v3.

5
k8s-python/README.md Normal file
View file

@ -0,0 +1,5 @@
## Requirements
```
pip install kubernetes
```

13
k8s-python/test-log.py Normal file
View file

@ -0,0 +1,13 @@
from kubernetes.client.rest import ApiException
from kubernetes import client, config
import sys
config.load_kube_config()
pod_name = sys.argv[1]
try:
api_instance = client.CoreV1Api()
api_response = api_instance.read_namespaced_pod_log(name=pod_name, namespace='default', container="zammad-railsserver")
print(api_response)
except ApiException as e:
print(f"Found exception in reading the logs: {e}")

View file

@ -4,7 +4,7 @@
#
# While trying to install python-ldap
FROM python:3.10.0-alpine3.15
FROM python:3.13.4-alpine3.22
WORKDIR /usr/src/app

View file

@ -1,7 +1,16 @@
all: requirements
IMAGE_NAME=harbor.k8s.ungleich.ch/ungleich-public/uncloud
IMAGE=$(IMAGE_NAME):$$(git describe --dirty)
all: requirements build
build:
sh -c 'docker build -t $(IMAGE) .'
pub: build
docker push $(IMAGE)
run: requirements
. ./env && python manage.py runserver
. ./venv/bin/activate && python manage.py runserver
requirements: venv
. ./venv/bin/activate && pip install -r requirements.txt

View file

@ -0,0 +1,45 @@
* Generally to be done for prod [44%]
** TODO Add description to product
- Maybe markdown later
** TODO Link orders to users
** TODO Maybe i18n on everything
** PROGRESS Re-understand get_context_data
- gets additional context such as other models / related models
- Unsure when the context data is being used.
- Usually used in the Detailview of the thing
- so likely referenced by a template or View
** TODO Prevent a product or resource to be named "product"
Because of initial['product'] = self.kwargs['product']
** DONE Add default values to resources
CLOSED: [2025-03-08 Sat 12:27]
** DONE Re-understand get_form_kwargs
CLOSED: [2025-03-08 Sat 11:48]
- Build the keyword arguments required to instantiate the form.
- Kinda defining the keys needed
https://docs.djangoproject.com/en/5.1/ref/class-based-views/mixins-editing/
** DONE Re-understand get_initial
CLOSED: [2025-03-08 Sat 11:48]
- Kinda getting the (initial) values for the keys
- populates form values
" Retrieve initial data for the form. By default, returns a
copy of initial."
https://docs.djangoproject.com/en/5.1/ref/class-based-views/mixins-editing/
** DONE Why are (one time) prices separate from resources?
CLOSED: [2025-03-08 Sat 11:31]
A: because duration prices need to be separate.
- onetime price seems to be reasonable to be inside resource
- Price per timeframe must be separate
- Thus onetime price was also added separately
* Bills [0%]
** TODO Show bills
** TODO Allow to create / send bills
** PROGRESS Add customer/client
* OIDC integration [%]
** Write code
** Test with authentik

View file

@ -12,9 +12,37 @@ machine. Use `kubectl get nodes` to verify minikube is up and running.
* `SECRET_KEY`
* `DEBUG`
* `DATABASE`
* Should be: POSTGRES_HOST, POSTGRES_DB, POSTGRES_USER, POSTRES_PASSWORD
## Objective for v1.0
* I can order a Nextcloud instance with a name, it gets created and
billed
### Ordering Nextcloud
* Being able to specify the details
* Being able to enter a domain name (pre-selected list)
### Logging in
* LDAP support
### Creating it
* Writing yaml file with argocd specification
### Billing it
* Handling the time frames
* Handling stripe
* Handling CH VAT
* Handling EU VAT
## Versions
#### Future (unplanned)
* When/where to add timeframe constraints
@ -35,11 +63,14 @@ machine. Use `kubectl get nodes` to verify minikube is up and running.
* yes: ModelAdmin.formfield_for_manytomany(db_field, request, **kwargs)¶
* resources should have a slug
* can be used as an identifier and non unique names
* Execute collectstatic for docker
* OIDC / use authentik
#### 3.1 (validation release, planned)
* Ensure that one resource cannot have multiple price_per_timeframe of
the same timeframe
* Add wireguard config support
#### 3.0.2 (planned)
@ -48,13 +79,15 @@ machine. Use `kubectl get nodes` to verify minikube is up and running.
#### 3.0.1 (planned)
NEXT STEP: CREATE THE ORDER AND RESOURCE ORDER OBJECTS
* Show products [done]
* Link to ProductOrderForm [done]
* Find suitable timeframes for a product [done]
* Continue to resources / add resources
* Need to list resources [done]
* Need to create manytomany relations for each resource resoluting
in ResourceOrders1
in ResourceOrders
* Need to pass in the price for the selected timeframe [done]
* On submit
* Create ProductOrder

View file

@ -2,10 +2,47 @@ from django.contrib import admin
from .models import *
@admin.register(UncloudConfiguration)
class UncloudConfigurationAdmin(admin.ModelAdmin):
list_display = ['ipv6_billing_prefix', 'billing_counter']
fields = ['ipv6_billing_prefix', 'billing_counter']
def has_add_permission(self, request):
# Only allow one configuration instance
return not UncloudConfiguration.objects.exists()
def has_delete_permission(self, request, obj=None):
# Don't allow deletion of configuration
return False
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = ['email', 'full_name', 'company_name', 'created_at']
list_filter = ['created_at', 'country']
search_fields = ['email', 'first_name', 'last_name', 'company_name']
readonly_fields = ['created_at', 'updated_at']
@admin.register(Bill)
class BillAdmin(admin.ModelAdmin):
list_display = ['bill_number', 'customer', 'period_start_date', 'period_end_date', 'status', 'total_amount', 'created_at']
list_filter = ['status', 'created_at', 'period_start_date']
search_fields = ['bill_number', 'customer__email', 'customer__company_name']
readonly_fields = ['bill_number', 'created_at', 'period_start', 'period_end', 'total_amount']
date_hierarchy = 'created_at'
def get_readonly_fields(self, request, obj=None):
"""Make period dates readonly if bill is not in draft status"""
readonly_fields = list(self.readonly_fields)
if obj and not obj.can_edit_dates:
readonly_fields.extend(['period_start_date', 'period_end_date'])
return readonly_fields
for m in [
Currency,
Order,
OneTimePrice,
PricePerTime,
Product,
@ -13,5 +50,7 @@ for m in [
Resource,
ResourceOrder,
TimeFrame,
Order,
BillLineItem,
]:
admin.site.register(m)

View file

@ -1,4 +1,9 @@
from django import forms
from django.forms import NumberInput
from django.utils import timezone
from datetime import datetime, timedelta
from .models import Customer, Order
class ProductOneTimeOrderForm(forms.Form):
"""
@ -10,9 +15,23 @@ class ProductOneTimeOrderForm(forms.Form):
def __init__(self, resources, *args, **kwargs):
super().__init__(*args, **kwargs)
for res in resources:
print(res)
field_name = f"{res.slug}"
self.fields[field_name] = forms.FloatField(required=True, label=res.name)
self.fields[field_name] = forms.FloatField(
required=True,
label=res.name,
min_value=res.minimum_units,
max_value=res.maximum_units,
widget=NumberInput(attrs={"step": res.step_size}))
# if res.minimum_units < res.maximum_units:
# self.fields[field_name] = forms.FloatField(
# required=True,
# label=res.name,
# min_value=res.minimum_units,
# max_value=res.maximum_units,
# widget=NumberInput(attrs={"step": res.step_size}))
# else:
# self.fields[field_name] = forms.FloatField(widget=forms.HiddenInput(attrs={'value': res.minimum_units}))
def clean(self):
cleaned_data = super().clean()
@ -21,7 +40,64 @@ class ProductOneTimeOrderForm(forms.Form):
class ProductOrderForm(ProductOneTimeOrderForm):
"""
For recurring products (might also have OneTime items
For recurring products (might also have OneTime items)
"""
timeframe = forms.SlugField(required=False, disabled=True)
class CreateBillForm(forms.Form):
"""
Form for staff to create bills for customers
"""
customer = forms.ModelChoiceField(
queryset=Customer.objects.all(),
empty_label="Select a customer"
)
period_start_date = forms.DateField(
widget=forms.DateInput(attrs={'type': 'date'}),
initial=lambda: timezone.now().date().replace(day=1), # First day of current month
help_text="Start date of billing period (time automatically set to 00:00:00)"
)
period_end_date = forms.DateField(
widget=forms.DateInput(attrs={'type': 'date'}),
initial=lambda: (timezone.now().date().replace(day=1) + timedelta(days=32)).replace(day=1) - timedelta(days=1), # Last day of current month
help_text="End date of billing period (time automatically set to 23:59:59)"
)
include_orders = forms.ModelMultipleChoiceField(
queryset=Order.objects.none(),
required=False,
widget=forms.CheckboxSelectMultiple,
help_text="Leave empty to include all active orders for the period"
)
notes = forms.CharField(
widget=forms.Textarea(attrs={'rows': 3}),
required=False,
help_text="Optional notes for this bill"
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# If customer is selected, filter orders
if 'customer' in self.data:
try:
customer_id = int(self.data.get('customer'))
self.fields['include_orders'].queryset = Order.objects.filter(customer_id=customer_id)
except (ValueError, TypeError):
pass
def clean(self):
cleaned_data = super().clean()
period_start_date = cleaned_data.get('period_start_date')
period_end_date = cleaned_data.get('period_end_date')
if period_start_date and period_end_date:
if period_start_date > period_end_date:
raise forms.ValidationError("Period start date must be before or equal to period end date")
return cleaned_data

View file

@ -1,75 +1,197 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from app.models import *
class Command(BaseCommand):
help = 'Add test data'
# def add_arguments(self, parser):
def add_arguments(self, parser):
#parser.add_argument('--username', type=str, required=True)
pass
def handle(self, *args, **options):
currency, created = Currency.objects.get_or_create(defaults=
{
"slug": "CHF",
"name": "Swiss Franc",
"short_name": "CHF"
})
for timeframe in [ (3600, "1 hour", "1-hour"),
(86400, "1 day", "1-day"),
(7*86400, "7 days", "7-days"),
(30*86400, "30 days", "30-days"),
(365*86400, "365 days", "365 days") ]:
TimeFrame.objects.get_or_create(slug=timeframe[2],
defaults=
{
"name": timeframe[1],
"seconds": timeframe[0]
})
currency = self.create_currency()
self.create_timeframes()
self.create_prices_per_time(currency)
self.create_resources(currency)
self.create_products()
self.create_sample_customer()
self.create_admin_user()
for ppt in [
("1-day", 1, currency),
("1-day", 2, currency),
("30-days", 10, currency), # Nextcloud
("30-days", 15, currency), # Gitea
("30-days", 35, currency), # Matrix
("30-days", 29, currency),
("30-days", 55, currency) ]:
tf = TimeFrame.objects.get(slug=ppt[0])
def create_currency(self):
"""Add CHF as currency"""
currency, created = Currency.objects.get_or_create(
slug="CHF",
defaults={
"name": "Swiss Franc",
"short_name": "CHF"
}
)
return currency
PricePerTime.objects.get_or_create(timeframe=tf,
value=ppt[1],
defaults=
{
"currency": currency
})
def create_timeframes(self):
"""Add standard timeframes"""
timeframes_data = [
(3600, "1 hour", "1-hour"),
(86400, "1 day", "1-day"),
(7*86400, "7 days", "7-days"),
(30*86400, "30 days", "30-days"),
(365*86400, "365 days", "365 days")
]
for res in [
("cpu-1", "CPU", "Core(s)", None, None),
("cpu-min-max", "CPU", "Core(s)", 1, 20),
("ram-1", "RAM", "GB", None, None),
("ram-min-max", "RAM", "GB", 1, 200),
("matrix-maintenance", "Matrix Maintenance Fee", "", 1, 1),
("nextcloud-maintenance", "Nextcloud Maintenance Fee", "", 1, 1),
("gitea-maintenance", "Gitea Maintenance Fee", "", 1, 1),
for seconds, name, slug in timeframes_data:
TimeFrame.objects.get_or_create(
slug=slug,
defaults={
"name": name,
"seconds": seconds
}
)
]:
Resource.objects.get_or_create(slug=res[0],
defaults=
{
"name": res[1],
"unit": res[2],
"minimum_units": res[3],
"maximum_units": res[4]
})
# Link to PPT -- later
# for ppt_res in res[5]:
# ppt = PricePerTime.objects.get(
def create_prices_per_time(self, currency):
"""Add typical prices per timeframe"""
prices_data = [
("1-day", 1),
("1-day", 2),
("30-days", 2), # HDD 100 GB
("30-days", 3), # CPU
("30-days", 3.5), # SSD Storage
("30-days", 4), # RAM
("30-days", 10), # Nextcloud
("30-days", 15), # Gitea
("30-days", 35), # Matrix
("30-days", 29),
("30-days", 55)
]
for timeframe_slug, value in prices_data:
tf = TimeFrame.objects.get(slug=timeframe_slug)
PricePerTime.objects.get_or_create(
timeframe=tf,
value=value,
defaults={
"currency": currency
}
)
for product in [
("matrix", "Matrix"),
("nextcloud", "Nextcloud"),
("gitea", "Gitea") ]:
Product.objects.get_or_create(slug=product[0],
defaults = { "name": product[1] })
def create_resources(self, currency):
"""Add typical resources"""
tf_30d = TimeFrame.objects.get(slug='30-days')
resources_data = [
# (slug, name, description, min, max, step_size, price_per_30days)
("cpu-1", "CPU", "Core(s)", None, None, 0.5, 3),
("cpu-min-max", "CPU", "Core(s)", 1, 20, 0.5, 3),
("ram-1", "RAM", "GB", None, None, 0.5, 4),
("ram-min-max", "RAM", "GB", 1, 200, 0.5, 4),
("storage-db", "Database-Storage", "GB", 10, None, 10, 3.5),
("storage-ssd", "SSD-Storage", "GB", 10, None, 10, 3.5),
("storage-hdd", "HDD-Storage", "GB", 100, None, 100, 2),
("matrix-maintenance", "Matrix Maintenance Fee", "", 1, 1, 1, 35),
("nextcloud-maintenance", "Nextcloud Maintenance Fee", "", 1, 1, 1, 10),
("gitea-maintenance", "Gitea Maintenance Fee", "", 1, 1, 1, 15),
]
for slug, name, unit, min_units, max_units, step_size, price in resources_data:
resource, created = Resource.objects.get_or_create(
slug=slug,
defaults={
"name": name,
"unit": unit,
"minimum_units": min_units,
"maximum_units": max_units,
"step_size": step_size
}
)
# If price is given, assign it
if price:
try:
ppt = PricePerTime.objects.get(timeframe=tf_30d, value=price)
resource.price_per_time.add(ppt)
except PricePerTime.DoesNotExist:
print(f"Warning: PricePerTime with value {price} for 30-days timeframe does not exist. Skipping price assignment for resource {slug}.")
def create_products(self):
"""Add test products"""
tf_30d = TimeFrame.objects.get(slug='30-days')
products_data = [
("matrix", "Matrix"),
("nextcloud", "Nextcloud"),
("gitea", "Gitea")
]
required_resources = [
"cpu-min-max",
"ram-min-max",
"storage-db",
"storage-hdd"
]
for slug, name in products_data:
product, created = Product.objects.get_or_create(
slug=slug,
defaults={"name": name}
)
# Add required resources
for resource_slug in required_resources:
print(f"Adding {resource_slug} to {product}")
product.resources.add(Resource.objects.get(slug=resource_slug))
# Add maintenance resource specific to this product
maintenance_resource_slug = f"{slug}-maintenance"
product.resources.add(Resource.objects.get(slug=maintenance_resource_slug))
# Every test product can be bought for the 30d timeframe
product.timeframes.add(tf_30d)
def create_sample_customer(self):
"""Add a sample customer"""
customer, created = Customer.objects.get_or_create(
email='customer@ungleich.ch',
defaults={
'first_name': 'Ungleich',
'last_name': 'Customer',
'company_name': 'ungleich customer',
'phone': '+41 44 534 66 22',
'address_line1': 'Technoparkstrasse 1',
'city': 'Zürich',
'postal_code': '8005',
'country': 'Switzerland'
}
)
if created:
print(f"Created sample customer: {customer}")
else:
print(f"Sample customer already exists: {customer}")
return customer
def create_admin_user(self):
"""Add an admin superuser"""
User = get_user_model()
admin_user, created = User.objects.get_or_create(
username='admin',
defaults={
'email': 'admin@uncloud.example.com',
'first_name': 'Admin',
'last_name': 'User',
'is_staff': True,
'is_superuser': True,
'is_active': True
}
)
if created:
admin_user.set_password('veryinsecure')
admin_user.save()
print(f"Created admin superuser: {admin_user.username}")
else:
print(f"Admin user already exists: {admin_user.username}")
return admin_user

View file

@ -1,8 +1,10 @@
# Generated by Django 4.0 on 2022-01-16 16:44
# Generated by Django 5.2.2 on 2025-06-17 04:58
from django.db import migrations, models
import app.models
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
@ -10,7 +12,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('uauth', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@ -24,34 +26,34 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name='PricePerTime',
name='Customer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.FloatField()),
('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')),
('email', models.EmailField(max_length=254, unique=True)),
('first_name', models.CharField(blank=True, max_length=150)),
('last_name', models.CharField(blank=True, max_length=150)),
('company_name', models.CharField(blank=True, max_length=255)),
('phone', models.CharField(blank=True, max_length=20)),
('address_line1', models.CharField(blank=True, max_length=255)),
('address_line2', models.CharField(blank=True, max_length=255)),
('city', models.CharField(blank=True, max_length=100)),
('postal_code', models.CharField(blank=True, max_length=20)),
('country', models.CharField(blank=True, max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['email'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(null=True, unique=True)),
('slug', models.SlugField(null=True, unique=True, validators=[app.models.validate_name_not_product])),
('name', models.CharField(max_length=128, unique=True)),
],
),
migrations.CreateModel(
name='Resource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(null=True, unique=True)),
('name', models.CharField(max_length=128)),
('unit', models.CharField(max_length=128)),
('minimum_units', models.FloatField(blank=True, null=True)),
('maximum_units', models.FloatField(blank=True, null=True)),
('step_size', models.FloatField(default=1)),
('price_per_time', models.ManyToManyField(blank=True, to='app.PricePerTime')),
],
),
migrations.CreateModel(
name='TimeFrame',
fields=[
@ -62,47 +64,36 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name='ResourceOrder',
name='UncloudConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.FloatField()),
('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.resource')),
('ipv6_billing_prefix', models.CharField(default='2001:db8::/64', help_text='IPv6 prefix for generating billing numbers (e.g., 2001:db8::/64)', max_length=39)),
('billing_counter', models.BigIntegerField(default=1)),
],
options={
'verbose_name': 'Uncloud Configuration',
'verbose_name_plural': 'Uncloud Configuration',
},
),
migrations.CreateModel(
name='ProductOrder',
name='Bill',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.product')),
('resources', models.ManyToManyField(to='app.ResourceOrder')),
('timeframe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe')),
],
),
migrations.AddField(
model_name='product',
name='resources',
field=models.ManyToManyField(blank=True, to='app.Resource'),
),
migrations.AddField(
model_name='product',
name='timeframes',
field=models.ManyToManyField(blank=True, to='app.TimeFrame'),
),
migrations.AddField(
model_name='pricepertime',
name='timeframe',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'),
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField(default=django.utils.timezone.now)),
('ending_date', models.DateTimeField(blank=True, null=True)),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uauth.user')),
('product', models.ManyToManyField(blank=True, to='app.ProductOrder')),
('period_start_date', models.DateField()),
('period_end_date', models.DateField()),
('period_start', models.DateTimeField(editable=False)),
('period_end', models.DateTimeField(editable=False)),
('bill_number', models.CharField(blank=True, max_length=39, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent'), ('paid', 'Paid'), ('overdue', 'Overdue'), ('cancelled', 'Cancelled')], default='draft', max_length=20)),
('due_date', models.DateField()),
('paid_date', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.customer')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='OneTimePrice',
@ -111,5 +102,89 @@ class Migration(migrations.Migration):
('value', models.FloatField()),
('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')),
],
options={
'ordering': ('value',),
},
),
migrations.CreateModel(
name='PricePerTime',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.FloatField()),
('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')),
('timeframe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe')),
],
),
migrations.CreateModel(
name='ProductOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.product')),
],
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField(default=django.utils.timezone.now)),
('ending_date', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.customer')),
('product', models.ManyToManyField(blank=True, to='app.productorder')),
],
),
migrations.CreateModel(
name='BillLineItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(max_length=255)),
('quantity', models.DecimalField(decimal_places=2, max_digits=10)),
('unit_price', models.DecimalField(decimal_places=2, max_digits=10)),
('bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='line_items', to='app.bill')),
('product_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='app.productorder')),
],
),
migrations.CreateModel(
name='Resource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(null=True, unique=True, validators=[app.models.validate_name_not_product])),
('name', models.CharField(max_length=128)),
('unit', models.CharField(max_length=128)),
('minimum_units', models.FloatField(blank=True, null=True)),
('maximum_units', models.FloatField(blank=True, null=True)),
('default_value', models.FloatField(blank=True, null=True)),
('step_size', models.FloatField(default=1)),
('onetime_price', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.onetimeprice')),
('price_per_time', models.ManyToManyField(blank=True, to='app.pricepertime')),
],
),
migrations.AddField(
model_name='product',
name='resources',
field=models.ManyToManyField(blank=True, to='app.resource'),
),
migrations.CreateModel(
name='ResourceOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.FloatField()),
('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.resource')),
],
),
migrations.AddField(
model_name='productorder',
name='resources',
field=models.ManyToManyField(to='app.resourceorder'),
),
migrations.AddField(
model_name='productorder',
name='timeframe',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'),
),
migrations.AddField(
model_name='product',
name='timeframes',
field=models.ManyToManyField(blank=True, to='app.timeframe'),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 4.0 on 2022-01-30 09:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='resource',
name='onetime_price',
field=models.ManyToManyField(blank=True, to='app.OneTimePrice'),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 4.0 on 2022-01-30 10:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('app', '0002_resource_onetime_price'),
]
operations = [
migrations.RemoveField(
model_name='resource',
name='onetime_price',
),
migrations.AddField(
model_name='resource',
name='onetime_price',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='app.onetimeprice'),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 4.0 on 2022-01-30 10:39
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('app', '0003_remove_resource_onetime_price_resource_onetime_price'),
]
operations = [
migrations.AlterModelOptions(
name='onetimeprice',
options={'ordering': ('value',)},
),
migrations.AlterField(
model_name='productorder',
name='timeframe',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'),
),
]

View file

@ -3,6 +3,21 @@ from django.contrib.auth import get_user_model
from django.utils import timezone
from django.urls import reverse
from django.db.models import Q
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import ipaddress
def validate_name_not_product(value):
"""
We want to prevent overriding our own code.
So the hardcoded name "product" may not be used as a product or resource name
"""
if value == "product":
raise ValidationError(
_("%(value)s is not allowed as the name"),
params={"value": value},
)
class Currency(models.Model):
slug = models.SlugField(null=True, unique=True)
@ -56,16 +71,19 @@ class PricePerTime(models.Model):
return f"{self.value}{self.currency.short_name}/{self.timeframe}"
class Resource(models.Model):
slug = models.SlugField(null=True, unique=True) # primary identifier
slug = models.SlugField(null=True, unique=True, validators=[validate_name_not_product]) # primary identifier
name = models.CharField(max_length=128, unique=False) # CPU, RAM
unit = models.CharField(max_length=128) # Count, GB
minimum_units = models.FloatField(null=True, blank=True) # might have min
maximum_units = models.FloatField(null=True, blank=True) # might have max
default_value = models.FloatField(null=True, blank=True) # default value to show
step_size = models.FloatField(default=1) # step size
price_per_time = models.ManyToManyField(PricePerTime, blank=True)
#onetime_price = models.ManyToManyField(OneTimePrice, blank=True)
onetime_price = models.ForeignKey(OneTimePrice, null=True, on_delete=models.CASCADE)
onetime_price = models.ForeignKey(OneTimePrice,
null=True, blank=True,
on_delete=models.CASCADE)
def __str__(self):
if self.minimum_units:
@ -88,7 +106,7 @@ class Product(models.Model):
Describes a product a user can buy
"""
slug = models.SlugField(null=True, unique=True)
slug = models.SlugField(null=True, unique=True, validators=[validate_name_not_product])
name = models.CharField(max_length=128, unique=True)
resources = models.ManyToManyField(Resource, blank=True) # List of REQUIRED resources
@ -159,10 +177,129 @@ class ProductOrder(models.Model):
return txt
class UncloudConfiguration(models.Model):
"""
Global configuration for uncloud instance
"""
# IPv6 prefix for billing numbers
ipv6_billing_prefix = models.CharField(
max_length=39, # Max length for IPv6 address
default='2001:db8::/64',
help_text='IPv6 prefix for generating billing numbers (e.g., 2001:db8::/64)'
)
# Counter for billing numbers
billing_counter = models.BigIntegerField(default=1)
# Singleton pattern - only one configuration should exist
class Meta:
verbose_name = 'Uncloud Configuration'
verbose_name_plural = 'Uncloud Configuration'
def save(self, *args, **kwargs):
# Validate IPv6 prefix
try:
network = ipaddress.IPv6Network(self.ipv6_billing_prefix, strict=False)
# Ensure we have enough host bits for billing numbers
if network.prefixlen >= 128:
raise ValidationError("IPv6 prefix must allow for host addresses (prefix length < 128)")
except ipaddress.AddressValueError:
raise ValidationError("Invalid IPv6 prefix format")
# Ensure only one configuration exists
if not self.pk and UncloudConfiguration.objects.exists():
raise ValidationError("Only one configuration instance is allowed")
super().save(*args, **kwargs)
def get_next_billing_ipv6(self):
"""
Generate the next IPv6 billing address and increment counter
"""
try:
network = ipaddress.IPv6Network(self.ipv6_billing_prefix, strict=False)
# Calculate the host address by adding the counter to the network address
host_address = network.network_address + self.billing_counter
# Ensure we don't exceed the network range
if host_address not in network:
raise ValidationError(f"Billing counter {self.billing_counter} exceeds network range {self.ipv6_billing_prefix}")
# Increment counter for next use
self.billing_counter += 1
self.save()
return str(host_address)
except Exception as e:
raise ValidationError(f"Error generating IPv6 billing address: {str(e)}")
@classmethod
def get_instance(cls):
"""
Get or create the singleton configuration instance
"""
config, created = cls.objects.get_or_create(
pk=1,
defaults={
'ipv6_billing_prefix': '2001:db8::/64',
'billing_counter': 1
}
)
return config
def __str__(self):
return f"Uncloud Config - IPv6 Prefix: {self.ipv6_billing_prefix}, Counter: {self.billing_counter}"
class Customer(models.Model):
"""
Customer model for tying orders to customers
Future: will be linked to authentik OIDC users
"""
email = models.EmailField(unique=True)
first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True)
company_name = models.CharField(max_length=255, blank=True)
# Contact information
phone = models.CharField(max_length=20, blank=True)
# Address fields
address_line1 = models.CharField(max_length=255, blank=True)
address_line2 = models.CharField(max_length=255, blank=True)
city = models.CharField(max_length=100, blank=True)
postal_code = models.CharField(max_length=20, blank=True)
country = models.CharField(max_length=100, blank=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Future: authentik integration
# authentik_user_id = models.CharField(max_length=255, blank=True, null=True, unique=True)
class Meta:
ordering = ['email']
def __str__(self):
if self.company_name:
return f"{self.company_name} ({self.email})"
elif self.first_name or self.last_name:
return f"{self.first_name} {self.last_name} ({self.email})".strip()
else:
return self.email
@property
def full_name(self):
return f"{self.first_name} {self.last_name}".strip()
class Order(models.Model):
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False)
customer = models.ForeignKey('Customer', on_delete=models.CASCADE)
# Remove the owner field since we're using customer now
# owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False)
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField(default=timezone.now)
@ -170,4 +307,106 @@ class Order(models.Model):
product = models.ManyToManyField(ProductOrder, blank=True)
#textconfigs = models.ManyToManyField(ResourceConfig)
def __str__(self):
return f"Order {self.id} for {self.customer}"
class Bill(models.Model):
"""
A bill for a customer covering a specific time period
"""
customer = models.ForeignKey('Customer', on_delete=models.CASCADE)
# Date range for billing period (staff sets these)
period_start_date = models.DateField()
period_end_date = models.DateField()
# Automatically calculated datetime period (start of day to end of day)
period_start = models.DateTimeField(editable=False)
period_end = models.DateTimeField(editable=False)
# Bill metadata - IPv6 address as billing number
bill_number = models.CharField(max_length=39, unique=True, blank=True) # IPv6 max length
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
# Bill status
STATUS_CHOICES = [
('draft', 'Draft'),
('sent', 'Sent'),
('paid', 'Paid'),
('overdue', 'Overdue'),
('cancelled', 'Cancelled'),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
# Payment information
due_date = models.DateField()
paid_date = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
def save(self, *args, **kwargs):
# Auto-calculate period_start and period_end from date fields
if self.period_start_date:
# Start of day (00:00:00)
self.period_start = timezone.make_aware(
timezone.datetime.combine(self.period_start_date, timezone.datetime.min.time())
)
if self.period_end_date:
# End of day (23:59:59.999999)
self.period_end = timezone.make_aware(
timezone.datetime.combine(self.period_end_date, timezone.datetime.max.time())
)
if not self.bill_number:
# Generate IPv6 billing number
config = UncloudConfiguration.get_instance()
self.bill_number = config.get_next_billing_ipv6()
super().save(*args, **kwargs)
@property
def total_amount(self):
"""Calculate total amount from all line items"""
return sum(item.total for item in self.line_items.all())
@property
def period_days(self):
"""Number of days this bill covers"""
return (self.period_end_date - self.period_start_date).days + 1
@property
def can_edit_dates(self):
"""Check if billing dates can still be edited"""
return self.status == 'draft'
def __str__(self):
return f"Bill {self.bill_number} for {self.customer} ({self.period_start_date} to {self.period_end_date})"
class BillLineItem(models.Model):
"""
Individual line items on a bill
"""
bill = models.ForeignKey(Bill, on_delete=models.CASCADE, related_name='line_items')
# Description of the line item
description = models.CharField(max_length=255)
# Quantity and pricing
quantity = models.DecimalField(max_digits=10, decimal_places=2)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
# Optional: link to the product order that generated this line item
product_order = models.ForeignKey(ProductOrder, on_delete=models.SET_NULL, null=True, blank=True)
# Calculated total (quantity * unit_price)
@property
def total(self):
return self.quantity * self.unit_price
def __str__(self):
return f"{self.description} - {self.quantity} x {self.unit_price}"

View file

@ -1,4 +1,7 @@
from django.shortcuts import get_object_or_404
from django.db import models
from django.utils import timezone
from datetime import timedelta
from .models import *
@ -21,9 +24,95 @@ def order_product(product, timeframe, formdata):
resource = get_object_or_404(Resource, slug=res)
ro = ResourceOrder.objects.create(value=value, resource=resource)
po.resources.add(ro)
return po
# Ordering without a timeframe
# if not timeframe:
# product = models.ForeignKey(Product, on_delete=models.CASCADE)
# timeframe = models.ForeignKey(TimeFrame, null=True, on_delete=models.CASCADE)
# resources = models.ManyToManyField(ResourceOrder)
def create_bill_for_customer(customer, period_start_date, period_end_date, created_by, include_orders=None, notes=""):
"""
Create a bill for a customer for a specific date range (covers full days)
Args:
customer: Customer object
period_start_date: date - start date of billing period
period_end_date: date - end date of billing period
created_by: User who is creating the bill
include_orders: list of Order IDs to include (if None, includes all active orders)
notes: optional notes for the bill
"""
# Create the bill (period_start/end will be auto-calculated from dates)
bill = Bill.objects.create(
customer=customer,
period_start_date=period_start_date,
period_end_date=period_end_date,
created_by=created_by,
due_date=period_end_date + timedelta(days=30), # Default 30 days payment term
)
# Get the auto-calculated period times
period_start = bill.period_start
period_end = bill.period_end
# Get orders to include
if include_orders:
orders = Order.objects.filter(id__in=include_orders, customer=customer)
else:
# Include orders that are active during the billing period
orders = Order.objects.filter(
customer=customer,
starting_date__lte=period_end,
).filter(
models.Q(ending_date__isnull=True) | models.Q(ending_date__gte=period_start)
)
# Create line items for each order
for order in orders:
for product_order in order.product.all():
# Handle recurring charges
if product_order.timeframe:
recurring_price = calculate_recurring_price(product_order, period_start, period_end)
if recurring_price > 0:
BillLineItem.objects.create(
bill=bill,
product_order=product_order,
description=f"{product_order.product.name} - {product_order.timeframe.name}",
quantity=1,
unit_price=recurring_price
)
# Handle one-time charges (only if order started during this billing period)
if order.starting_date >= period_start and order.starting_date <= period_end:
onetime_price = calculate_onetime_price(product_order)
if onetime_price > 0:
BillLineItem.objects.create(
bill=bill,
product_order=product_order,
description=f"{product_order.product.name} - Setup Fee",
quantity=1,
unit_price=onetime_price
)
return bill
def calculate_recurring_price(product_order, period_start, period_end):
"""
Calculate recurring price for a product order within a time period
"""
# This is a placeholder - implement actual pricing logic
return 0.0
def calculate_onetime_price(product_order):
"""
Calculate one-time price for a product order
"""
# This is a placeholder - implement actual pricing logic
return 0.0

View file

@ -8,5 +8,5 @@ you to manage all your resources.
<h2>What can I do with uncloud?</h2>
<ul>
<li>You can<a href="{% url 'products' %}">order products</a></li>
<li>You can <a href="{% url 'products' %}">order products</a></li>
</ul>

View file

@ -0,0 +1,13 @@
<h2>Order Confirmation</h2>
<p>
Thank you for the order. The details are below:
</p>
<div class="order-details">
Order ID: {{ product_order.id }}<br/>
Product: {{ product_order.product.name }}
</div>
<br/>
<div>
<a href="{% url 'index' %}">Go back to the home page</a>
</div>

View file

@ -1,7 +1,7 @@
<h2>Order {{ product }}</h2>
{% if timeframe %}
<p>Timeframe: {{ timeframe }}</p>
<p>Selected timeframe: {{ timeframe }}</p>
{% endif %}
<form method="post" >
@ -9,5 +9,5 @@
<table>
{{ form }}
</table>
<button type="submit" class="btn btn-primary">Submit</button>
<button type="submit" class="btn btn-primary">Order</button>
</form>

View file

@ -10,19 +10,35 @@ from .models import *
from .forms import *
from .services import *
class OrderConfirmationView(TemplateView):
template_name = 'app/order_confirmation.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
po_id = self.request.session.pop('product_order_id', None)
context['product_order'] = get_object_or_404(ProductOrder, id=po_id)
return context
class ProductOneTimeOrderView(FormView):
form_class = ProductOneTimeOrderForm
template_name = 'app/productorder_form.html'
def get_success_url(self):
return "/"
return reverse("order-confirmation")
def get_form_kwargs(self):
"""
Keys for the form
"""
kwargs = super().get_form_kwargs()
# Set the product so the form can retrieve the resources
product = get_object_or_404(Product, slug=self.kwargs['product'])
kwargs['resources'] = product.resources.all()
print(f"kwargs = {kwargs}")
return kwargs
def get_initial(self):
@ -30,10 +46,15 @@ class ProductOneTimeOrderView(FormView):
Initial values for the form
"""
initial = super().get_initial()
initial = super().get_initial()
initial['product'] = self.kwargs['product']
product = get_object_or_404(Product, slug=self.kwargs['product'])
for res in product.resources.all():
if res.default_value:
initial[res.slug] = res.default_value
if 'timeframe' in self.kwargs:
initial['timeframe'] = self.kwargs['timeframe']
return initial
@ -58,8 +79,8 @@ class ProductOneTimeOrderView(FormView):
else:
timeframe = None
order_product(product, timeframe, form.cleaned_data)
po = order_product(product, timeframe, form.cleaned_data)
self.request.session['product_order_id'] = po.id
return super().form_valid(form)
class ProductOrderView(ProductOneTimeOrderView):
@ -86,6 +107,10 @@ class ProductSelectView(CreateView):
fields = ['product' ]
class IndexView(TemplateView):
"""
The starting page containing a short intro
"""
template_name = "app/index.html"
class Yearly(TemplateView):

View file

@ -0,0 +1,7 @@
#!/bin/sh
rm app/migrations/0*
rm db.sqlite3
python3 manage.py makemigrations
python3 manage.py migrate
python3 manage.py test-data

View file

@ -2,7 +2,7 @@
set -x
name=uncloud:$(git describe)
name=ungleich/uncloud:$(git describe)
docker build -t ${name} .
# check for args

View file

@ -0,0 +1,28 @@
version: '3.8'
services:
db:
image: postgres:14.1-alpine
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- '5432:5432'
volumes:
- db:/var/lib/postgresql/data
uncloud:
image: postgres:14.1-alpine
depends_on:
- db
ports:
- 3000:3000
environment:
DB_HOST: db
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: postgres
SECRET_KEY: "an almost good secret key"
volumes:
db:
driver: local

View file

@ -1,4 +1,4 @@
# Django basics
Django==4.0
Django==5.2.2
djangorestframework
django-auth-ldap

View file

@ -23,6 +23,7 @@ urlpatterns = [
path('order/<slug:product>/', appviews.ProductOrderView.as_view(), name='product-order'),
path('order/recurring/<slug:product>/<slug:timeframe>/', appviews.ProductOrderView.as_view(), name='product-order-tf'),
path('order/onetime/<slug:product>/', appviews.ProductOneTimeOrderView.as_view(), name='product-order-onetime'),
path('order-confirmation', appviews.OrderConfirmationView.as_view(), name='order-confirmation'),
path('product/', appviews.ProductListView.as_view(), name='products'),
path('product/<slug:slug>/', appviews.ProductDetailView.as_view(), name='product-detail'),
path('', appviews.IndexView.as_view(), name='index'),