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
|
||||
.DS_Store
|
||||
static/CACHE/
|
||||
.aider*
|
||||
|
|
|
|||
72
README.md
72
README.md
|
|
@ -1,71 +1,3 @@
|
|||
# Uncloud
|
||||
## README
|
||||
|
||||
Cloud management platform, the ungleich way.
|
||||
|
||||
|
||||
[](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`
|
||||
Ignore most of it, checkout uncloud_v3.
|
||||
|
|
|
|||
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
|
||||
|
||||
FROM python:3.10.0-alpine3.15
|
||||
FROM python:3.13.4-alpine3.22
|
||||
|
||||
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
|
||||
. ./env && python manage.py runserver
|
||||
. ./venv/bin/activate && python manage.py runserver
|
||||
|
||||
requirements: venv
|
||||
. ./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`
|
||||
* `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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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>
|
||||
|
||||
{% 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>
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
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
|
||||
|
||||
name=uncloud:$(git describe)
|
||||
name=ungleich/uncloud:$(git describe)
|
||||
docker build -t ${name} .
|
||||
|
||||
# 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==4.0
|
||||
Django==5.2.2
|
||||
djangorestframework
|
||||
django-auth-ldap
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue