forked from uncloud/uncloud
Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56a3572680 | ||
|
|
0d88ef7f32 | ||
|
|
f6e568c67c | ||
|
|
11c7e7be51 | ||
|
|
393dc3fd75 | ||
|
|
9dc207ea4c | ||
|
|
f881908b74 | ||
|
|
34a934a90b | ||
|
|
ef4ca9d879 | ||
|
|
6fa10ef6d5 | ||
|
|
95dfe62858 | ||
|
|
05eea37349 | ||
| 51851910c6 | |||
| 86cb43a3c1 | |||
| 93013c8997 | |||
|
|
1648355fe7 | ||
|
|
c50d688171 | ||
|
|
72f47dec7c | ||
|
|
8668e173b9 |
39 changed files with 957 additions and 269 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -27,3 +27,4 @@ dist/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
.DS_Store
|
.DS_Store
|
||||||
static/CACHE/
|
static/CACHE/
|
||||||
|
.aider*
|
||||||
|
|
|
||||||
72
README.md
72
README.md
|
|
@ -1,71 +1,3 @@
|
||||||
# Uncloud
|
## README
|
||||||
|
|
||||||
Cloud management platform, the ungleich way.
|
Ignore most of it, checkout uncloud_v3.
|
||||||
|
|
||||||
|
|
||||||
[](https://code.ungleich.ch/uncloud/uncloud/commits/master)
|
|
||||||
[](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`
|
|
||||||
|
|
|
||||||
5
k8s-python/README.md
Normal file
5
k8s-python/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install kubernetes
|
||||||
|
```
|
||||||
13
k8s-python/test-log.py
Normal file
13
k8s-python/test-log.py
Normal 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}")
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
#
|
#
|
||||||
# While trying to install python-ldap
|
# While trying to install python-ldap
|
||||||
|
|
||||||
FROM python:3.10.0-alpine3.15
|
FROM python:3.13.4-alpine3.22
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
run: requirements
|
||||||
. ./env && python manage.py runserver
|
. ./venv/bin/activate && python manage.py runserver
|
||||||
|
|
||||||
requirements: venv
|
requirements: venv
|
||||||
. ./venv/bin/activate && pip install -r requirements.txt
|
. ./venv/bin/activate && pip install -r requirements.txt
|
||||||
|
|
|
||||||
45
uncloud_v3/README-2025.org
Normal file
45
uncloud_v3/README-2025.org
Normal 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
|
||||||
|
|
@ -12,9 +12,37 @@ machine. Use `kubectl get nodes` to verify minikube is up and running.
|
||||||
* `SECRET_KEY`
|
* `SECRET_KEY`
|
||||||
* `DEBUG`
|
* `DEBUG`
|
||||||
* `DATABASE`
|
* `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
|
## Versions
|
||||||
|
|
||||||
|
|
||||||
#### Future (unplanned)
|
#### Future (unplanned)
|
||||||
|
|
||||||
* When/where to add timeframe constraints
|
* 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)¶
|
* yes: ModelAdmin.formfield_for_manytomany(db_field, request, **kwargs)¶
|
||||||
* resources should have a slug
|
* resources should have a slug
|
||||||
* can be used as an identifier and non unique names
|
* can be used as an identifier and non unique names
|
||||||
|
* Execute collectstatic for docker
|
||||||
|
* OIDC / use authentik
|
||||||
|
|
||||||
#### 3.1 (validation release, planned)
|
#### 3.1 (validation release, planned)
|
||||||
|
|
||||||
* Ensure that one resource cannot have multiple price_per_timeframe of
|
* Ensure that one resource cannot have multiple price_per_timeframe of
|
||||||
the same timeframe
|
the same timeframe
|
||||||
|
* Add wireguard config support
|
||||||
|
|
||||||
|
|
||||||
#### 3.0.2 (planned)
|
#### 3.0.2 (planned)
|
||||||
|
|
@ -48,13 +79,15 @@ machine. Use `kubectl get nodes` to verify minikube is up and running.
|
||||||
|
|
||||||
#### 3.0.1 (planned)
|
#### 3.0.1 (planned)
|
||||||
|
|
||||||
|
NEXT STEP: CREATE THE ORDER AND RESOURCE ORDER OBJECTS
|
||||||
|
|
||||||
* Show products [done]
|
* Show products [done]
|
||||||
* Link to ProductOrderForm [done]
|
* Link to ProductOrderForm [done]
|
||||||
* Find suitable timeframes for a product [done]
|
* Find suitable timeframes for a product [done]
|
||||||
* Continue to resources / add resources
|
* Continue to resources / add resources
|
||||||
* Need to list resources [done]
|
* Need to list resources [done]
|
||||||
* Need to create manytomany relations for each resource resoluting
|
* Need to create manytomany relations for each resource resoluting
|
||||||
in ResourceOrders1
|
in ResourceOrders
|
||||||
* Need to pass in the price for the selected timeframe [done]
|
* Need to pass in the price for the selected timeframe [done]
|
||||||
* On submit
|
* On submit
|
||||||
* Create ProductOrder
|
* Create ProductOrder
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,47 @@ from django.contrib import admin
|
||||||
|
|
||||||
from .models import *
|
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 [
|
for m in [
|
||||||
Currency,
|
Currency,
|
||||||
Order,
|
|
||||||
OneTimePrice,
|
OneTimePrice,
|
||||||
PricePerTime,
|
PricePerTime,
|
||||||
Product,
|
Product,
|
||||||
|
|
@ -13,5 +50,7 @@ for m in [
|
||||||
Resource,
|
Resource,
|
||||||
ResourceOrder,
|
ResourceOrder,
|
||||||
TimeFrame,
|
TimeFrame,
|
||||||
|
Order,
|
||||||
|
BillLineItem,
|
||||||
]:
|
]:
|
||||||
admin.site.register(m)
|
admin.site.register(m)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
from django import forms
|
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):
|
class ProductOneTimeOrderForm(forms.Form):
|
||||||
"""
|
"""
|
||||||
|
|
@ -10,9 +15,23 @@ class ProductOneTimeOrderForm(forms.Form):
|
||||||
def __init__(self, resources, *args, **kwargs):
|
def __init__(self, resources, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
for res in resources:
|
for res in resources:
|
||||||
print(res)
|
|
||||||
field_name = f"{res.slug}"
|
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):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
|
|
@ -21,7 +40,64 @@ class ProductOneTimeOrderForm(forms.Form):
|
||||||
|
|
||||||
class ProductOrderForm(ProductOneTimeOrderForm):
|
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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,197 @@
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from app.models import *
|
from app.models import *
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Add test data'
|
help = 'Add test data'
|
||||||
|
|
||||||
# def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
#parser.add_argument('--username', type=str, required=True)
|
#parser.add_argument('--username', type=str, required=True)
|
||||||
|
pass
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
currency, created = Currency.objects.get_or_create(defaults=
|
currency = self.create_currency()
|
||||||
{
|
self.create_timeframes()
|
||||||
"slug": "CHF",
|
self.create_prices_per_time(currency)
|
||||||
"name": "Swiss Franc",
|
self.create_resources(currency)
|
||||||
"short_name": "CHF"
|
self.create_products()
|
||||||
})
|
self.create_sample_customer()
|
||||||
for timeframe in [ (3600, "1 hour", "1-hour"),
|
self.create_admin_user()
|
||||||
(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]
|
|
||||||
})
|
|
||||||
|
|
||||||
for ppt in [
|
def create_currency(self):
|
||||||
("1-day", 1, currency),
|
"""Add CHF as currency"""
|
||||||
("1-day", 2, currency),
|
currency, created = Currency.objects.get_or_create(
|
||||||
("30-days", 10, currency), # Nextcloud
|
slug="CHF",
|
||||||
("30-days", 15, currency), # Gitea
|
defaults={
|
||||||
("30-days", 35, currency), # Matrix
|
"name": "Swiss Franc",
|
||||||
("30-days", 29, currency),
|
"short_name": "CHF"
|
||||||
("30-days", 55, currency) ]:
|
}
|
||||||
tf = TimeFrame.objects.get(slug=ppt[0])
|
)
|
||||||
|
return currency
|
||||||
|
|
||||||
PricePerTime.objects.get_or_create(timeframe=tf,
|
def create_timeframes(self):
|
||||||
value=ppt[1],
|
"""Add standard timeframes"""
|
||||||
defaults=
|
timeframes_data = [
|
||||||
{
|
(3600, "1 hour", "1-hour"),
|
||||||
"currency": currency
|
(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 [
|
for seconds, name, slug in timeframes_data:
|
||||||
("cpu-1", "CPU", "Core(s)", None, None),
|
TimeFrame.objects.get_or_create(
|
||||||
("cpu-min-max", "CPU", "Core(s)", 1, 20),
|
slug=slug,
|
||||||
("ram-1", "RAM", "GB", None, None),
|
defaults={
|
||||||
("ram-min-max", "RAM", "GB", 1, 200),
|
"name": name,
|
||||||
("matrix-maintenance", "Matrix Maintenance Fee", "", 1, 1),
|
"seconds": seconds
|
||||||
("nextcloud-maintenance", "Nextcloud Maintenance Fee", "", 1, 1),
|
}
|
||||||
("gitea-maintenance", "Gitea Maintenance Fee", "", 1, 1),
|
)
|
||||||
|
|
||||||
]:
|
def create_prices_per_time(self, currency):
|
||||||
Resource.objects.get_or_create(slug=res[0],
|
"""Add typical prices per timeframe"""
|
||||||
defaults=
|
prices_data = [
|
||||||
{
|
("1-day", 1),
|
||||||
"name": res[1],
|
("1-day", 2),
|
||||||
"unit": res[2],
|
("30-days", 2), # HDD 100 GB
|
||||||
"minimum_units": res[3],
|
("30-days", 3), # CPU
|
||||||
"maximum_units": res[4]
|
("30-days", 3.5), # SSD Storage
|
||||||
})
|
("30-days", 4), # RAM
|
||||||
# Link to PPT -- later
|
("30-days", 10), # Nextcloud
|
||||||
# for ppt_res in res[5]:
|
("30-days", 15), # Gitea
|
||||||
# ppt = PricePerTime.objects.get(
|
("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 [
|
def create_resources(self, currency):
|
||||||
("matrix", "Matrix"),
|
"""Add typical resources"""
|
||||||
("nextcloud", "Nextcloud"),
|
tf_30d = TimeFrame.objects.get(slug='30-days')
|
||||||
("gitea", "Gitea") ]:
|
|
||||||
Product.objects.get_or_create(slug=product[0],
|
resources_data = [
|
||||||
defaults = { "name": product[1] })
|
# (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
|
||||||
|
|
|
||||||
|
|
@ -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.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
@ -10,7 +12,7 @@ class Migration(migrations.Migration):
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('uauth', '0001_initial'),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
@ -24,34 +26,34 @@ class Migration(migrations.Migration):
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='PricePerTime',
|
name='Customer',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('value', models.FloatField()),
|
('email', models.EmailField(max_length=254, unique=True)),
|
||||||
('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')),
|
('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(
|
migrations.CreateModel(
|
||||||
name='Product',
|
name='Product',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('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)),
|
('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(
|
migrations.CreateModel(
|
||||||
name='TimeFrame',
|
name='TimeFrame',
|
||||||
fields=[
|
fields=[
|
||||||
|
|
@ -62,47 +64,36 @@ class Migration(migrations.Migration):
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='ResourceOrder',
|
name='UncloudConfiguration',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('value', models.FloatField()),
|
('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)),
|
||||||
('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.resource')),
|
('billing_counter', models.BigIntegerField(default=1)),
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Uncloud Configuration',
|
||||||
|
'verbose_name_plural': 'Uncloud Configuration',
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='ProductOrder',
|
name='Bill',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('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')),
|
('period_start_date', models.DateField()),
|
||||||
('resources', models.ManyToManyField(to='app.ResourceOrder')),
|
('period_end_date', models.DateField()),
|
||||||
('timeframe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe')),
|
('period_start', models.DateTimeField(editable=False)),
|
||||||
],
|
('period_end', models.DateTimeField(editable=False)),
|
||||||
),
|
('bill_number', models.CharField(blank=True, max_length=39, unique=True)),
|
||||||
migrations.AddField(
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
model_name='product',
|
('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent'), ('paid', 'Paid'), ('overdue', 'Overdue'), ('cancelled', 'Cancelled')], default='draft', max_length=20)),
|
||||||
name='resources',
|
('due_date', models.DateField()),
|
||||||
field=models.ManyToManyField(blank=True, to='app.Resource'),
|
('paid_date', models.DateTimeField(blank=True, null=True)),
|
||||||
),
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||||
migrations.AddField(
|
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.customer')),
|
||||||
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')),
|
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='OneTimePrice',
|
name='OneTimePrice',
|
||||||
|
|
@ -111,5 +102,89 @@ class Migration(migrations.Migration):
|
||||||
('value', models.FloatField()),
|
('value', models.FloatField()),
|
||||||
('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')),
|
('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'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -3,6 +3,21 @@ from django.contrib.auth import get_user_model
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.db.models import Q
|
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):
|
class Currency(models.Model):
|
||||||
slug = models.SlugField(null=True, unique=True)
|
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}"
|
return f"{self.value}{self.currency.short_name}/{self.timeframe}"
|
||||||
|
|
||||||
class Resource(models.Model):
|
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
|
name = models.CharField(max_length=128, unique=False) # CPU, RAM
|
||||||
unit = models.CharField(max_length=128) # Count, GB
|
unit = models.CharField(max_length=128) # Count, GB
|
||||||
minimum_units = models.FloatField(null=True, blank=True) # might have min
|
minimum_units = models.FloatField(null=True, blank=True) # might have min
|
||||||
maximum_units = models.FloatField(null=True, blank=True) # might have max
|
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
|
step_size = models.FloatField(default=1) # step size
|
||||||
|
|
||||||
price_per_time = models.ManyToManyField(PricePerTime, blank=True)
|
price_per_time = models.ManyToManyField(PricePerTime, blank=True)
|
||||||
#onetime_price = models.ManyToManyField(OneTimePrice, blank=True)
|
onetime_price = models.ForeignKey(OneTimePrice,
|
||||||
onetime_price = models.ForeignKey(OneTimePrice, null=True, on_delete=models.CASCADE)
|
null=True, blank=True,
|
||||||
|
on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.minimum_units:
|
if self.minimum_units:
|
||||||
|
|
@ -88,7 +106,7 @@ class Product(models.Model):
|
||||||
Describes a product a user can buy
|
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)
|
name = models.CharField(max_length=128, unique=True)
|
||||||
|
|
||||||
resources = models.ManyToManyField(Resource, blank=True) # List of REQUIRED resources
|
resources = models.ManyToManyField(Resource, blank=True) # List of REQUIRED resources
|
||||||
|
|
@ -159,10 +177,129 @@ class ProductOrder(models.Model):
|
||||||
|
|
||||||
return txt
|
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):
|
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)
|
creation_date = models.DateTimeField(auto_now_add=True)
|
||||||
starting_date = models.DateTimeField(default=timezone.now)
|
starting_date = models.DateTimeField(default=timezone.now)
|
||||||
|
|
@ -170,4 +307,106 @@ class Order(models.Model):
|
||||||
|
|
||||||
product = models.ManyToManyField(ProductOrder, blank=True)
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
from django.shortcuts import get_object_or_404
|
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 *
|
from .models import *
|
||||||
|
|
||||||
|
|
@ -21,9 +24,95 @@ def order_product(product, timeframe, formdata):
|
||||||
resource = get_object_or_404(Resource, slug=res)
|
resource = get_object_or_404(Resource, slug=res)
|
||||||
ro = ResourceOrder.objects.create(value=value, resource=resource)
|
ro = ResourceOrder.objects.create(value=value, resource=resource)
|
||||||
po.resources.add(ro)
|
po.resources.add(ro)
|
||||||
|
return po
|
||||||
|
|
||||||
# Ordering without a timeframe
|
# Ordering without a timeframe
|
||||||
# if not timeframe:
|
# if not timeframe:
|
||||||
# product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
# product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||||
# timeframe = models.ForeignKey(TimeFrame, null=True, on_delete=models.CASCADE)
|
# timeframe = models.ForeignKey(TimeFrame, null=True, on_delete=models.CASCADE)
|
||||||
# resources = models.ManyToManyField(ResourceOrder)
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@ you to manage all your resources.
|
||||||
<h2>What can I do with uncloud?</h2>
|
<h2>What can I do with uncloud?</h2>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>You can<a href="{% url 'products' %}">order products</a></li>
|
<li>You can <a href="{% url 'products' %}">order products</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
13
uncloud_v3/app/templates/app/order_confirmation.html
Normal file
13
uncloud_v3/app/templates/app/order_confirmation.html
Normal 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>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<h2>Order {{ product }}</h2>
|
<h2>Order {{ product }}</h2>
|
||||||
|
|
||||||
{% if timeframe %}
|
{% if timeframe %}
|
||||||
<p>Timeframe: {{ timeframe }}</p>
|
<p>Selected timeframe: {{ timeframe }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" >
|
<form method="post" >
|
||||||
|
|
@ -9,5 +9,5 @@
|
||||||
<table>
|
<table>
|
||||||
{{ form }}
|
{{ form }}
|
||||||
</table>
|
</table>
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
<button type="submit" class="btn btn-primary">Order</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,35 @@ from .models import *
|
||||||
from .forms import *
|
from .forms import *
|
||||||
from .services 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):
|
class ProductOneTimeOrderView(FormView):
|
||||||
form_class = ProductOneTimeOrderForm
|
form_class = ProductOneTimeOrderForm
|
||||||
template_name = 'app/productorder_form.html'
|
template_name = 'app/productorder_form.html'
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return "/"
|
return reverse("order-confirmation")
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
|
"""
|
||||||
|
Keys for the form
|
||||||
|
"""
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
|
|
||||||
# Set the product so the form can retrieve the resources
|
# Set the product so the form can retrieve the resources
|
||||||
product = get_object_or_404(Product, slug=self.kwargs['product'])
|
product = get_object_or_404(Product, slug=self.kwargs['product'])
|
||||||
kwargs['resources'] = product.resources.all()
|
kwargs['resources'] = product.resources.all()
|
||||||
|
|
||||||
|
print(f"kwargs = {kwargs}")
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
|
|
@ -30,10 +46,15 @@ class ProductOneTimeOrderView(FormView):
|
||||||
Initial values for the form
|
Initial values for the form
|
||||||
"""
|
"""
|
||||||
|
|
||||||
initial = super().get_initial()
|
|
||||||
|
|
||||||
|
initial = super().get_initial()
|
||||||
initial['product'] = self.kwargs['product']
|
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:
|
if 'timeframe' in self.kwargs:
|
||||||
initial['timeframe'] = self.kwargs['timeframe']
|
initial['timeframe'] = self.kwargs['timeframe']
|
||||||
return initial
|
return initial
|
||||||
|
|
@ -58,8 +79,8 @@ class ProductOneTimeOrderView(FormView):
|
||||||
else:
|
else:
|
||||||
timeframe = None
|
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)
|
return super().form_valid(form)
|
||||||
|
|
||||||
class ProductOrderView(ProductOneTimeOrderView):
|
class ProductOrderView(ProductOneTimeOrderView):
|
||||||
|
|
@ -86,6 +107,10 @@ class ProductSelectView(CreateView):
|
||||||
fields = ['product' ]
|
fields = ['product' ]
|
||||||
|
|
||||||
class IndexView(TemplateView):
|
class IndexView(TemplateView):
|
||||||
|
"""
|
||||||
|
The starting page containing a short intro
|
||||||
|
"""
|
||||||
|
|
||||||
template_name = "app/index.html"
|
template_name = "app/index.html"
|
||||||
|
|
||||||
class Yearly(TemplateView):
|
class Yearly(TemplateView):
|
||||||
|
|
|
||||||
7
uncloud_v3/bin/reset-migrations.sh
Normal file
7
uncloud_v3/bin/reset-migrations.sh
Normal 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
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
name=uncloud:$(git describe)
|
name=ungleich/uncloud:$(git describe)
|
||||||
docker build -t ${name} .
|
docker build -t ${name} .
|
||||||
|
|
||||||
# check for args
|
# check for args
|
||||||
|
|
|
||||||
28
uncloud_v3/docker-compose.yml
Normal file
28
uncloud_v3/docker-compose.yml
Normal 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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Django basics
|
# Django basics
|
||||||
Django==4.0
|
Django==5.2.2
|
||||||
djangorestframework
|
djangorestframework
|
||||||
django-auth-ldap
|
django-auth-ldap
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ urlpatterns = [
|
||||||
path('order/<slug:product>/', appviews.ProductOrderView.as_view(), name='product-order'),
|
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/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/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/', appviews.ProductListView.as_view(), name='products'),
|
||||||
path('product/<slug:slug>/', appviews.ProductDetailView.as_view(), name='product-detail'),
|
path('product/<slug:slug>/', appviews.ProductDetailView.as_view(), name='product-detail'),
|
||||||
path('', appviews.IndexView.as_view(), name='index'),
|
path('', appviews.IndexView.as_view(), name='index'),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue