From 8668e173b9321a2c09260aa9351433e51d203d45 Mon Sep 17 00:00:00 2001 From: PCoder Date: Tue, 22 Feb 2022 12:02:32 +0530 Subject: [PATCH 01/16] Do not allow to choose resource unit when min == max --- uncloud_v3/app/forms.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/uncloud_v3/app/forms.py b/uncloud_v3/app/forms.py index af42715..e36ab1a 100644 --- a/uncloud_v3/app/forms.py +++ b/uncloud_v3/app/forms.py @@ -1,4 +1,6 @@ from django import forms +from django.forms import NumberInput + class ProductOneTimeOrderForm(forms.Form): """ @@ -12,7 +14,15 @@ class ProductOneTimeOrderForm(forms.Form): for res in resources: print(res) field_name = f"{res.slug}" - self.fields[field_name] = forms.FloatField(required=True, label=res.name) + 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() From 72f47dec7cd751a73e07b5bb4f54b0c8556dbacf Mon Sep 17 00:00:00 2001 From: PCoder Date: Tue, 22 Feb 2022 13:32:31 +0530 Subject: [PATCH 02/16] Return product_order object after creation --- uncloud_v3/app/services.py | 1 + 1 file changed, 1 insertion(+) diff --git a/uncloud_v3/app/services.py b/uncloud_v3/app/services.py index ce98c66..1db25e1 100644 --- a/uncloud_v3/app/services.py +++ b/uncloud_v3/app/services.py @@ -21,6 +21,7 @@ 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: From c50d6881714ee718ed30d383c6401b598c942d35 Mon Sep 17 00:00:00 2001 From: PCoder Date: Tue, 22 Feb 2022 13:33:29 +0530 Subject: [PATCH 03/16] Add order-confirmation view and take user to this after the purchase --- uncloud_v3/app/views.py | 17 ++++++++++++++--- uncloud_v3/uncloud/urls.py | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/uncloud_v3/app/views.py b/uncloud_v3/app/views.py index 0478578..db1cde9 100644 --- a/uncloud_v3/app/views.py +++ b/uncloud_v3/app/views.py @@ -10,12 +10,23 @@ 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): kwargs = super().get_form_kwargs() @@ -58,8 +69,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): diff --git a/uncloud_v3/uncloud/urls.py b/uncloud_v3/uncloud/urls.py index 33b46b8..bebb926 100644 --- a/uncloud_v3/uncloud/urls.py +++ b/uncloud_v3/uncloud/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('order//', appviews.ProductOrderView.as_view(), name='product-order'), path('order/recurring///', appviews.ProductOrderView.as_view(), name='product-order-tf'), path('order/onetime//', 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.ProductDetailView.as_view(), name='product-detail'), path('', appviews.IndexView.as_view(), name='index'), From 1648355fe7069b97151734f37731857da5fd94ae Mon Sep 17 00:00:00 2001 From: PCoder Date: Tue, 22 Feb 2022 13:33:45 +0530 Subject: [PATCH 04/16] Add order_confirmation template --- .../app/templates/app/order_confirmation.html | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 uncloud_v3/app/templates/app/order_confirmation.html diff --git a/uncloud_v3/app/templates/app/order_confirmation.html b/uncloud_v3/app/templates/app/order_confirmation.html new file mode 100644 index 0000000..ed57513 --- /dev/null +++ b/uncloud_v3/app/templates/app/order_confirmation.html @@ -0,0 +1,13 @@ +

Order Confirmation

+ +

+Thank you for the order. The details are below: +

+
+ Order ID: {{ product_order.id }}
+ Product: {{ product_order.product.name }} +
+
+ From 05eea37349b2255345dbf689f3f1ea3f74afda3e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 16 Jul 2022 18:22:52 +0200 Subject: [PATCH 05/16] Cleanup and more demo products --- uncloud_v3/Makefile | 10 +++- uncloud_v3/README.md | 7 ++- uncloud_v3/app/forms.py | 27 +++++---- .../app/management/commands/test-data.py | 57 +++++++++++++++---- uncloud_v3/app/models.py | 5 +- uncloud_v3/app/templates/app/index.html | 2 +- .../app/templates/app/productorder_form.html | 2 +- uncloud_v3/app/views.py | 4 ++ uncloud_v3/build.sh | 2 +- uncloud_v3/requirements.txt | 2 +- 10 files changed, 89 insertions(+), 29 deletions(-) diff --git a/uncloud_v3/Makefile b/uncloud_v3/Makefile index cadb844..cfcf30f 100644 --- a/uncloud_v3/Makefile +++ b/uncloud_v3/Makefile @@ -1,4 +1,12 @@ -all: requirements +IMAGE=ungleich/uncloud + +all: requirements build + +build: + sh -c 'docker build -t $(IMAGE):$$(git describe) .' + +pub: build + docker push $(IMAGE):$$(git describe) run: requirements . ./env && python manage.py runserver diff --git a/uncloud_v3/README.md b/uncloud_v3/README.md index 1d53093..353ca3b 100644 --- a/uncloud_v3/README.md +++ b/uncloud_v3/README.md @@ -12,6 +12,7 @@ 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 ## Versions @@ -35,11 +36,13 @@ 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 #### 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 +51,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 diff --git a/uncloud_v3/app/forms.py b/uncloud_v3/app/forms.py index e36ab1a..3d9d815 100644 --- a/uncloud_v3/app/forms.py +++ b/uncloud_v3/app/forms.py @@ -14,15 +14,22 @@ class ProductOneTimeOrderForm(forms.Form): for res in resources: print(res) field_name = f"{res.slug}" - 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})) + 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() @@ -31,7 +38,7 @@ 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) diff --git a/uncloud_v3/app/management/commands/test-data.py b/uncloud_v3/app/management/commands/test-data.py index 7e01b28..63066ae 100644 --- a/uncloud_v3/app/management/commands/test-data.py +++ b/uncloud_v3/app/management/commands/test-data.py @@ -9,12 +9,15 @@ class Command(BaseCommand): #parser.add_argument('--username', type=str, required=True) def handle(self, *args, **options): + # Add CHF as currency currency, created = Currency.objects.get_or_create(defaults= { "slug": "CHF", "name": "Swiss Franc", "short_name": "CHF" }) + + # Add standard timeframes for timeframe in [ (3600, "1 hour", "1-hour"), (86400, "1 day", "1-day"), (7*86400, "7 days", "7-days"), @@ -27,9 +30,16 @@ class Command(BaseCommand): "seconds": timeframe[0] }) + tf_30d = TimeFrame.objects.get(slug='30-days') + + # Add typical prices per timeframe for ppt in [ ("1-day", 1, currency), ("1-day", 2, currency), + ("30-days", 2, currency), # HDD 100 GB + ("30-days", 3, currency), # CPU + ("30-days", 3.5, currency), # SSD Storage + ("30-days", 4, currency), # RAM ("30-days", 10, currency), # Nextcloud ("30-days", 15, currency), # Gitea ("30-days", 35, currency), # Matrix @@ -44,32 +54,57 @@ class Command(BaseCommand): "currency": currency }) + # Add typical resources 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), + # 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, None, 35), + ("nextcloud-maintenance", "Nextcloud Maintenance Fee", "", 1, 1, None, 10), + ("gitea-maintenance", "Gitea Maintenance Fee", "", 1, 1, None, 15), ]: - Resource.objects.get_or_create(slug=res[0], + this_res, created = Resource.objects.get_or_create(slug=res[0], defaults= { "name": res[1], "unit": res[2], "minimum_units": res[3], - "maximum_units": res[4] + "maximum_units": res[4], + "step_size": res[5] }) + # If price is given, assign it + if res[6]: + ppt = PricePerTime.objects.get(timeframe=tf_30d, value=res[6]) + this_res.price_per_time.add(ppt) + + + # Link resources to prices per time frame + # Link to PPT -- later # for ppt_res in res[5]: # ppt = PricePerTime.objects.get( - + # Add test products for product in [ ("matrix", "Matrix"), ("nextcloud", "Nextcloud"), ("gitea", "Gitea") ]: - Product.objects.get_or_create(slug=product[0], + p, created = Product.objects.get_or_create(slug=product[0], defaults = { "name": product[1] }) + + for req_res in [ "cpu-min-max", + "ram-min-max", + "storage-db", + "storage-hdd" ]: + print(f"Adding {req_res} to {p}") + p.resources.add(Resource.objects.get(slug=req_res)) + + p.resources.add(Resource.objects.get(slug=f"{product[0]}-maintenance")) + # Every test product can be bought for the 30d timeframe + p.timeframes.add(tf_30d) diff --git a/uncloud_v3/app/models.py b/uncloud_v3/app/models.py index 3ae8a56..d7bdb07 100644 --- a/uncloud_v3/app/models.py +++ b/uncloud_v3/app/models.py @@ -64,8 +64,9 @@ class Resource(models.Model): 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: diff --git a/uncloud_v3/app/templates/app/index.html b/uncloud_v3/app/templates/app/index.html index 2ada352..6d9a8ac 100644 --- a/uncloud_v3/app/templates/app/index.html +++ b/uncloud_v3/app/templates/app/index.html @@ -8,5 +8,5 @@ you to manage all your resources.

What can I do with uncloud?

diff --git a/uncloud_v3/app/templates/app/productorder_form.html b/uncloud_v3/app/templates/app/productorder_form.html index 0235c78..8fde29a 100644 --- a/uncloud_v3/app/templates/app/productorder_form.html +++ b/uncloud_v3/app/templates/app/productorder_form.html @@ -9,5 +9,5 @@ {{ form }}
- + diff --git a/uncloud_v3/app/views.py b/uncloud_v3/app/views.py index db1cde9..a8cb299 100644 --- a/uncloud_v3/app/views.py +++ b/uncloud_v3/app/views.py @@ -97,6 +97,10 @@ class ProductSelectView(CreateView): fields = ['product' ] class IndexView(TemplateView): + """ + The starting page containing a short intro + """ + template_name = "app/index.html" class Yearly(TemplateView): diff --git a/uncloud_v3/build.sh b/uncloud_v3/build.sh index 1e7f492..29b6992 100755 --- a/uncloud_v3/build.sh +++ b/uncloud_v3/build.sh @@ -2,7 +2,7 @@ set -x -name=uncloud:$(git describe) +name=ungleich/uncloud:$(git describe) docker build -t ${name} . # check for args diff --git a/uncloud_v3/requirements.txt b/uncloud_v3/requirements.txt index 673b4a9..c3b17a4 100644 --- a/uncloud_v3/requirements.txt +++ b/uncloud_v3/requirements.txt @@ -1,4 +1,4 @@ # Django basics -Django==4.0 +Django==4.0.5 djangorestframework django-auth-ldap From 95dfe6285865eb84631df94d4b67216823e415d7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 5 Feb 2023 21:13:30 +0100 Subject: [PATCH 06/16] upgrade to python 3.11.1 --- uncloud_v3/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud_v3/Dockerfile b/uncloud_v3/Dockerfile index c953d76..4cf5c01 100644 --- a/uncloud_v3/Dockerfile +++ b/uncloud_v3/Dockerfile @@ -4,7 +4,7 @@ # # While trying to install python-ldap -FROM python:3.10.0-alpine3.15 +FROM python:3.11.1-alpine3.17 WORKDIR /usr/src/app From 6fa10ef6d56a9d03bfceefc691833dfb19082809 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 25 Nov 2023 22:34:38 +0100 Subject: [PATCH 07/16] very old rename? --- {uncloud_v3 => k8s}/helm/.helmignore | 0 {uncloud_v3 => k8s}/helm/Chart.yaml | 0 {uncloud_v3 => k8s}/helm/templates/NOTES.txt | 0 {uncloud_v3 => k8s}/helm/templates/_helpers.tpl | 0 {uncloud_v3 => k8s}/helm/templates/deployment.yaml | 0 {uncloud_v3 => k8s}/helm/templates/hpa.yaml | 0 {uncloud_v3 => k8s}/helm/templates/ingress.yaml | 0 {uncloud_v3 => k8s}/helm/templates/service.yaml | 0 {uncloud_v3 => k8s}/helm/templates/serviceaccount.yaml | 0 {uncloud_v3 => k8s}/helm/templates/tests/test-connection.yaml | 0 {uncloud_v3 => k8s}/helm/values.yaml | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename {uncloud_v3 => k8s}/helm/.helmignore (100%) rename {uncloud_v3 => k8s}/helm/Chart.yaml (100%) rename {uncloud_v3 => k8s}/helm/templates/NOTES.txt (100%) rename {uncloud_v3 => k8s}/helm/templates/_helpers.tpl (100%) rename {uncloud_v3 => k8s}/helm/templates/deployment.yaml (100%) rename {uncloud_v3 => k8s}/helm/templates/hpa.yaml (100%) rename {uncloud_v3 => k8s}/helm/templates/ingress.yaml (100%) rename {uncloud_v3 => k8s}/helm/templates/service.yaml (100%) rename {uncloud_v3 => k8s}/helm/templates/serviceaccount.yaml (100%) rename {uncloud_v3 => k8s}/helm/templates/tests/test-connection.yaml (100%) rename {uncloud_v3 => k8s}/helm/values.yaml (100%) diff --git a/uncloud_v3/helm/.helmignore b/k8s/helm/.helmignore similarity index 100% rename from uncloud_v3/helm/.helmignore rename to k8s/helm/.helmignore diff --git a/uncloud_v3/helm/Chart.yaml b/k8s/helm/Chart.yaml similarity index 100% rename from uncloud_v3/helm/Chart.yaml rename to k8s/helm/Chart.yaml diff --git a/uncloud_v3/helm/templates/NOTES.txt b/k8s/helm/templates/NOTES.txt similarity index 100% rename from uncloud_v3/helm/templates/NOTES.txt rename to k8s/helm/templates/NOTES.txt diff --git a/uncloud_v3/helm/templates/_helpers.tpl b/k8s/helm/templates/_helpers.tpl similarity index 100% rename from uncloud_v3/helm/templates/_helpers.tpl rename to k8s/helm/templates/_helpers.tpl diff --git a/uncloud_v3/helm/templates/deployment.yaml b/k8s/helm/templates/deployment.yaml similarity index 100% rename from uncloud_v3/helm/templates/deployment.yaml rename to k8s/helm/templates/deployment.yaml diff --git a/uncloud_v3/helm/templates/hpa.yaml b/k8s/helm/templates/hpa.yaml similarity index 100% rename from uncloud_v3/helm/templates/hpa.yaml rename to k8s/helm/templates/hpa.yaml diff --git a/uncloud_v3/helm/templates/ingress.yaml b/k8s/helm/templates/ingress.yaml similarity index 100% rename from uncloud_v3/helm/templates/ingress.yaml rename to k8s/helm/templates/ingress.yaml diff --git a/uncloud_v3/helm/templates/service.yaml b/k8s/helm/templates/service.yaml similarity index 100% rename from uncloud_v3/helm/templates/service.yaml rename to k8s/helm/templates/service.yaml diff --git a/uncloud_v3/helm/templates/serviceaccount.yaml b/k8s/helm/templates/serviceaccount.yaml similarity index 100% rename from uncloud_v3/helm/templates/serviceaccount.yaml rename to k8s/helm/templates/serviceaccount.yaml diff --git a/uncloud_v3/helm/templates/tests/test-connection.yaml b/k8s/helm/templates/tests/test-connection.yaml similarity index 100% rename from uncloud_v3/helm/templates/tests/test-connection.yaml rename to k8s/helm/templates/tests/test-connection.yaml diff --git a/uncloud_v3/helm/values.yaml b/k8s/helm/values.yaml similarity index 100% rename from uncloud_v3/helm/values.yaml rename to k8s/helm/values.yaml From ef4ca9d879711797d5f5f3d901ab8d95f6fe051a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 25 Feb 2024 16:30:05 +0900 Subject: [PATCH 08/16] [uncloud_v3] update to django 5.0.2 --- uncloud_v3/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud_v3/requirements.txt b/uncloud_v3/requirements.txt index c3b17a4..5db70a3 100644 --- a/uncloud_v3/requirements.txt +++ b/uncloud_v3/requirements.txt @@ -1,4 +1,4 @@ # Django basics -Django==4.0.5 +Django==5.0.2 djangorestframework django-auth-ldap From 34a934a90b0a747fcfa19b400ccabc73686045e0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 8 Mar 2025 11:04:46 +0900 Subject: [PATCH 09/16] update from old stuff --- README.md | 72 +-------------------------- {bin => archive}/deploy.sh | 0 {bin => archive}/fix-alpine-ldap_r.sh | 0 k8s-python/README.md | 5 ++ k8s-python/test-log.py | 13 +++++ uncloud_v3/README.md | 27 ++++++++++ 6 files changed, 47 insertions(+), 70 deletions(-) rename {bin => archive}/deploy.sh (100%) rename {bin => archive}/fix-alpine-ldap_r.sh (100%) create mode 100644 k8s-python/README.md create mode 100644 k8s-python/test-log.py diff --git a/README.md b/README.md index 84753c4..be6f9bb 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,3 @@ -# Uncloud +## README -Cloud management platform, the ungleich way. - - -[![pipeline status](https://code.ungleich.ch/uncloud/uncloud/badges/master/pipeline.svg)](https://code.ungleich.ch/uncloud/uncloud/commits/master) -[![coverage report](https://code.ungleich.ch/uncloud/uncloud/badges/master/coverage.svg)](https://code.ungleich.ch/uncloud/uncloud/commits/master) - -## Useful commands - -* `./manage.py import-vat-rates path/to/csv` -* `./manage.py createsuperuser` - -## Development setup - -Install system dependencies: - -* On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium` -* sudo apt-get install libpq-dev python-dev libxml2-dev libxslt1-dev libldap2-dev libsasl2-dev libffi-dev -* On Archlinux, [libldap24](https://aur.archlinux.org/packages/libldap24) is needed - - -NOTE: you will need to configure a LDAP server and credentials for authentication. See `uncloud/settings.py`. - -``` -# Initialize virtualenv. -» virtualenv .venv -Using base prefix '/usr' -New python executable in /home/fnux/Workspace/ungleich/uncloud/uncloud/.venv/bin/python3 -Also creating executable in /home/fnux/Workspace/ungleich/uncloud/uncloud/.venv/bin/python -Installing setuptools, pip, wheel... -done. - -# Enter virtualenv. -» source .venv/bin/activate - -# Install dependencies. -» pip install -r requirements.txt -[...] - -# Run migrations. -» ./manage.py migrate -Operations to perform: - Apply all migrations: admin, auth, contenttypes, opennebula, sessions, uncloud_auth, uncloud_net, uncloud_pay, uncloud_service, uncloud_vm -Running migrations: - [...] - -# Run webserver. -» ./manage.py runserver -Watching for file changes with StatReloader -Performing system checks... - -System check identified no issues (0 silenced). -May 07, 2020 - 10:17:08 -Django version 3.0.6, using settings 'uncloud.settings' -Starting development server at http://127.0.0.1:8000/ -Quit the server with CONTROL-C. -``` -### Run Background Job Queue -We use Django Q to handle the asynchronous code and Background Cron jobs -To start the workers make sure first that Redis or the Django Q broker is working and you can edit it's settings in the settings file. -``` -./manage.py qcluster -``` - -### Note on PGSQL - -If you want to use Postgres: - -* Install on configure PGSQL on your base system. -* OR use a container! `podman run --rm -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -it postgres:latest` +Ignore most of it, checkout uncloud_v3. diff --git a/bin/deploy.sh b/archive/deploy.sh similarity index 100% rename from bin/deploy.sh rename to archive/deploy.sh diff --git a/bin/fix-alpine-ldap_r.sh b/archive/fix-alpine-ldap_r.sh similarity index 100% rename from bin/fix-alpine-ldap_r.sh rename to archive/fix-alpine-ldap_r.sh diff --git a/k8s-python/README.md b/k8s-python/README.md new file mode 100644 index 0000000..d82f61e --- /dev/null +++ b/k8s-python/README.md @@ -0,0 +1,5 @@ +## Requirements + +``` +pip install kubernetes +``` diff --git a/k8s-python/test-log.py b/k8s-python/test-log.py new file mode 100644 index 0000000..471a769 --- /dev/null +++ b/k8s-python/test-log.py @@ -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}") diff --git a/uncloud_v3/README.md b/uncloud_v3/README.md index 353ca3b..1dffc98 100644 --- a/uncloud_v3/README.md +++ b/uncloud_v3/README.md @@ -14,8 +14,35 @@ machine. Use `kubectl get nodes` to verify minikube is up and running. * `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 From f881908b74f1df820d0c17a3a0c99e2602abcc4d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 8 Mar 2025 11:04:20 +0900 Subject: [PATCH 10/16] uncloud_v3 old changes --- ...5_alter_productorder_timeframe_and_more.py | 24 ++++++++++++++++ uncloud_v3/docker-compose.yml | 28 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 uncloud_v3/app/migrations/0005_alter_productorder_timeframe_and_more.py create mode 100644 uncloud_v3/docker-compose.yml diff --git a/uncloud_v3/app/migrations/0005_alter_productorder_timeframe_and_more.py b/uncloud_v3/app/migrations/0005_alter_productorder_timeframe_and_more.py new file mode 100644 index 0000000..713ad20 --- /dev/null +++ b/uncloud_v3/app/migrations/0005_alter_productorder_timeframe_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.6 on 2023-02-05 20:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0004_alter_onetimeprice_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='productorder', + name='timeframe', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'), + ), + migrations.AlterField( + model_name='resource', + name='onetime_price', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.onetimeprice'), + ), + ] diff --git a/uncloud_v3/docker-compose.yml b/uncloud_v3/docker-compose.yml new file mode 100644 index 0000000..552d3ec --- /dev/null +++ b/uncloud_v3/docker-compose.yml @@ -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 From 9dc207ea4c71f519a8eb0ed01b7dc55dde568130 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 8 Mar 2025 12:31:57 +0900 Subject: [PATCH 11/16] prevent slug = product for product and resource --- uncloud_v3/README-2025.org | 44 +++++++++++++++++++ uncloud_v3/README.md | 1 + uncloud_v3/app/forms.py | 1 - .../migrations/0006_resource_default_value.py | 18 ++++++++ ..._alter_product_slug_alter_resource_slug.py | 24 ++++++++++ uncloud_v3/app/models.py | 20 ++++++++- .../app/templates/app/productorder_form.html | 2 +- uncloud_v3/app/views.py | 12 ++++- 8 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 uncloud_v3/README-2025.org create mode 100644 uncloud_v3/app/migrations/0006_resource_default_value.py create mode 100644 uncloud_v3/app/migrations/0007_alter_product_slug_alter_resource_slug.py diff --git a/uncloud_v3/README-2025.org b/uncloud_v3/README-2025.org new file mode 100644 index 0000000..185e8ab --- /dev/null +++ b/uncloud_v3/README-2025.org @@ -0,0 +1,44 @@ +* 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 +* OIDC integration [%] +** Write code +** Test with authentik diff --git a/uncloud_v3/README.md b/uncloud_v3/README.md index 1dffc98..17a44af 100644 --- a/uncloud_v3/README.md +++ b/uncloud_v3/README.md @@ -64,6 +64,7 @@ machine. Use `kubectl get nodes` to verify minikube is up and running. * 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) diff --git a/uncloud_v3/app/forms.py b/uncloud_v3/app/forms.py index 3d9d815..fdfbb89 100644 --- a/uncloud_v3/app/forms.py +++ b/uncloud_v3/app/forms.py @@ -12,7 +12,6 @@ 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, diff --git a/uncloud_v3/app/migrations/0006_resource_default_value.py b/uncloud_v3/app/migrations/0006_resource_default_value.py new file mode 100644 index 0000000..f628c66 --- /dev/null +++ b/uncloud_v3/app/migrations/0006_resource_default_value.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2025-03-08 02:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0005_alter_productorder_timeframe_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='resource', + name='default_value', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/uncloud_v3/app/migrations/0007_alter_product_slug_alter_resource_slug.py b/uncloud_v3/app/migrations/0007_alter_product_slug_alter_resource_slug.py new file mode 100644 index 0000000..2ec2b5d --- /dev/null +++ b/uncloud_v3/app/migrations/0007_alter_product_slug_alter_resource_slug.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.2 on 2025-03-08 03:31 + +import app.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0006_resource_default_value'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='slug', + field=models.SlugField(null=True, unique=True, validators=[app.models.validate_name_not_product]), + ), + migrations.AlterField( + model_name='resource', + name='slug', + field=models.SlugField(null=True, unique=True, validators=[app.models.validate_name_not_product]), + ), + ] diff --git a/uncloud_v3/app/models.py b/uncloud_v3/app/models.py index d7bdb07..daf3d63 100644 --- a/uncloud_v3/app/models.py +++ b/uncloud_v3/app/models.py @@ -3,6 +3,20 @@ 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 _ + +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,11 +70,12 @@ 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) @@ -68,6 +83,7 @@ class Resource(models.Model): null=True, blank=True, on_delete=models.CASCADE) + def __str__(self): if self.minimum_units: minimum = self.minimum_units @@ -89,7 +105,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 diff --git a/uncloud_v3/app/templates/app/productorder_form.html b/uncloud_v3/app/templates/app/productorder_form.html index 8fde29a..84e7420 100644 --- a/uncloud_v3/app/templates/app/productorder_form.html +++ b/uncloud_v3/app/templates/app/productorder_form.html @@ -1,7 +1,7 @@

Order {{ product }}

{% if timeframe %} -

Timeframe: {{ timeframe }}

+

Selected timeframe: {{ timeframe }}

{% endif %}
diff --git a/uncloud_v3/app/views.py b/uncloud_v3/app/views.py index a8cb299..2ee139a 100644 --- a/uncloud_v3/app/views.py +++ b/uncloud_v3/app/views.py @@ -29,11 +29,16 @@ class ProductOneTimeOrderView(FormView): 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): @@ -41,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 From 393dc3fd75e0390f60db04a28e855c0e2a9f0be9 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Jun 2025 21:10:40 +0900 Subject: [PATCH 12/16] [uncloud_v3] update python, django and image location --- uncloud_v3/Dockerfile | 2 +- uncloud_v3/Makefile | 7 ++++--- uncloud_v3/requirements.txt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/uncloud_v3/Dockerfile b/uncloud_v3/Dockerfile index 4cf5c01..9217342 100644 --- a/uncloud_v3/Dockerfile +++ b/uncloud_v3/Dockerfile @@ -4,7 +4,7 @@ # # While trying to install python-ldap -FROM python:3.11.1-alpine3.17 +FROM python:3.13.4-alpine3.22 WORKDIR /usr/src/app diff --git a/uncloud_v3/Makefile b/uncloud_v3/Makefile index cfcf30f..9d116ad 100644 --- a/uncloud_v3/Makefile +++ b/uncloud_v3/Makefile @@ -1,12 +1,13 @@ -IMAGE=ungleich/uncloud +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):$$(git describe) .' + sh -c 'docker build -t $(IMAGE) .' pub: build - docker push $(IMAGE):$$(git describe) + docker push $(IMAGE) run: requirements . ./env && python manage.py runserver diff --git a/uncloud_v3/requirements.txt b/uncloud_v3/requirements.txt index 5db70a3..b540b4a 100644 --- a/uncloud_v3/requirements.txt +++ b/uncloud_v3/requirements.txt @@ -1,4 +1,4 @@ # Django basics -Django==5.0.2 +Django==5.2.2 djangorestframework django-auth-ldap From 11c7e7be516e60aad583d81205d9bb4d4b86f429 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 9 Jun 2025 13:51:22 +0900 Subject: [PATCH 13/16] uncloud_v3: refactor bootstrap --- uncloud_v3/Makefile | 2 +- uncloud_v3/README-2025.org | 1 + .../app/management/commands/test-data.py | 208 ++++++++++-------- 3 files changed, 124 insertions(+), 87 deletions(-) diff --git a/uncloud_v3/Makefile b/uncloud_v3/Makefile index 9d116ad..264524b 100644 --- a/uncloud_v3/Makefile +++ b/uncloud_v3/Makefile @@ -10,7 +10,7 @@ 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 diff --git a/uncloud_v3/README-2025.org b/uncloud_v3/README-2025.org index 185e8ab..6bfa83f 100644 --- a/uncloud_v3/README-2025.org +++ b/uncloud_v3/README-2025.org @@ -39,6 +39,7 @@ CLOSED: [2025-03-08 Sat 11:31] * Bills [0%] ** TODO Show bills ** TODO Allow to create / send bills +** PROGRESS Add customer/client * OIDC integration [%] ** Write code ** Test with authentik diff --git a/uncloud_v3/app/management/commands/test-data.py b/uncloud_v3/app/management/commands/test-data.py index 63066ae..9560677 100644 --- a/uncloud_v3/app/management/commands/test-data.py +++ b/uncloud_v3/app/management/commands/test-data.py @@ -5,106 +5,142 @@ 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): - # Add CHF as currency - currency, created = Currency.objects.get_or_create(defaults= - { - "slug": "CHF", - "name": "Swiss Franc", - "short_name": "CHF" - }) + currency = self.create_currency() + self.create_timeframes() + self.create_prices_per_time(currency) + self.create_resources(currency) + self.create_products() - # Add standard timeframes - 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] - }) + 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 + 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 seconds, name, slug in timeframes_data: + TimeFrame.objects.get_or_create( + slug=slug, + defaults={ + "name": name, + "seconds": seconds + } + ) + + 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 + } + ) + + def create_resources(self, currency): + """Add typical resources""" tf_30d = TimeFrame.objects.get(slug='30-days') - # Add typical prices per timeframe - for ppt in [ - ("1-day", 1, currency), - ("1-day", 2, currency), - ("30-days", 2, currency), # HDD 100 GB - ("30-days", 3, currency), # CPU - ("30-days", 3.5, currency), # SSD Storage - ("30-days", 4, currency), # RAM - ("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]) + 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), + ] - PricePerTime.objects.get_or_create(timeframe=tf, - value=ppt[1], - defaults= - { - "currency": currency - }) + 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 + } + ) - # Add typical resources - for res in [ - # 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, None, 35), - ("nextcloud-maintenance", "Nextcloud Maintenance Fee", "", 1, 1, None, 10), - ("gitea-maintenance", "Gitea Maintenance Fee", "", 1, 1, None, 15), - - ]: - this_res, created = Resource.objects.get_or_create(slug=res[0], - defaults= - { - "name": res[1], - "unit": res[2], - "minimum_units": res[3], - "maximum_units": res[4], - "step_size": res[5] - }) # If price is given, assign it - if res[6]: - ppt = PricePerTime.objects.get(timeframe=tf_30d, value=res[6]) - this_res.price_per_time.add(ppt) + 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') - # Link resources to prices per time frame + products_data = [ + ("matrix", "Matrix"), + ("nextcloud", "Nextcloud"), + ("gitea", "Gitea") + ] - # Link to PPT -- later - # for ppt_res in res[5]: - # ppt = PricePerTime.objects.get( + required_resources = [ + "cpu-min-max", + "ram-min-max", + "storage-db", + "storage-hdd" + ] - # Add test products - for product in [ - ("matrix", "Matrix"), - ("nextcloud", "Nextcloud"), - ("gitea", "Gitea") ]: - p, created = Product.objects.get_or_create(slug=product[0], - defaults = { "name": product[1] }) + for slug, name in products_data: + product, created = Product.objects.get_or_create( + slug=slug, + defaults={"name": name} + ) - for req_res in [ "cpu-min-max", - "ram-min-max", - "storage-db", - "storage-hdd" ]: - print(f"Adding {req_res} to {p}") - p.resources.add(Resource.objects.get(slug=req_res)) + # 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)) - p.resources.add(Resource.objects.get(slug=f"{product[0]}-maintenance")) # Every test product can be bought for the 30d timeframe - p.timeframes.add(tf_30d) + product.timeframes.add(tf_30d) From f6e568c67c9c52bf010826a993649614d94b4aed Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Jun 2025 13:46:16 +0900 Subject: [PATCH 14/16] Add Customer model and billing system with IPv6 billing numbers - Add Customer model with contact and address information - Update Order model to reference Customer instead of User - Add Bill and BillLineItem models for billing functionality - Add UncloudConfiguration for IPv6 prefix management - Bills use IPv6 addresses as billing numbers with configurable prefix - Add billing service functions for creating bills and calculating prices - Add CreateBillForm for staff to create bills - Update admin interface for all new models - Add sample customer creation to test-data command - Bills automatically set period_start/end to beginning/end of billing_date --- uncloud_v3/app/admin.py | 24 +- uncloud_v3/app/forms.py | 43 ++++ .../app/management/commands/test-data.py | 24 ++ uncloud_v3/app/migrations/0001_initial.py | 116 ++++++---- .../migrations/0002_resource_onetime_price.py | 18 -- ..._uncloudconfiguration_bill_billlineitem.py | 57 +++++ ...ce_onetime_price_resource_onetime_price.py | 23 -- ...004_alter_onetimeprice_options_and_more.py | 23 -- ...5_alter_productorder_timeframe_and_more.py | 24 -- .../migrations/0006_resource_default_value.py | 18 -- ..._alter_product_slug_alter_resource_slug.py | 24 -- uncloud_v3/app/models.py | 208 +++++++++++++++++- uncloud_v3/app/services.py | 88 +++++++- 13 files changed, 513 insertions(+), 177 deletions(-) delete mode 100644 uncloud_v3/app/migrations/0002_resource_onetime_price.py create mode 100644 uncloud_v3/app/migrations/0002_uncloudconfiguration_bill_billlineitem.py delete mode 100644 uncloud_v3/app/migrations/0003_remove_resource_onetime_price_resource_onetime_price.py delete mode 100644 uncloud_v3/app/migrations/0004_alter_onetimeprice_options_and_more.py delete mode 100644 uncloud_v3/app/migrations/0005_alter_productorder_timeframe_and_more.py delete mode 100644 uncloud_v3/app/migrations/0006_resource_default_value.py delete mode 100644 uncloud_v3/app/migrations/0007_alter_product_slug_alter_resource_slug.py diff --git a/uncloud_v3/app/admin.py b/uncloud_v3/app/admin.py index 27ac5b2..f404f0b 100644 --- a/uncloud_v3/app/admin.py +++ b/uncloud_v3/app/admin.py @@ -2,10 +2,29 @@ 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'] for m in [ Currency, - Order, OneTimePrice, PricePerTime, Product, @@ -13,5 +32,8 @@ for m in [ Resource, ResourceOrder, TimeFrame, + Order, + Bill, + BillLineItem, ]: admin.site.register(m) diff --git a/uncloud_v3/app/forms.py b/uncloud_v3/app/forms.py index fdfbb89..b6ee64b 100644 --- a/uncloud_v3/app/forms.py +++ b/uncloud_v3/app/forms.py @@ -1,5 +1,8 @@ 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): @@ -41,3 +44,43 @@ class ProductOrderForm(ProductOneTimeOrderForm): """ 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" + ) + + billing_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date'}), + initial=timezone.now().date(), + help_text="The date this bill covers (automatically uses start/end of day)" + ) + + include_orders = forms.ModelMultipleChoiceField( + queryset=Order.objects.none(), + required=False, + widget=forms.CheckboxSelectMultiple, + help_text="Leave empty to include all active orders for the day" + ) + + 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 diff --git a/uncloud_v3/app/management/commands/test-data.py b/uncloud_v3/app/management/commands/test-data.py index 9560677..1171cf0 100644 --- a/uncloud_v3/app/management/commands/test-data.py +++ b/uncloud_v3/app/management/commands/test-data.py @@ -15,6 +15,7 @@ class Command(BaseCommand): self.create_prices_per_time(currency) self.create_resources(currency) self.create_products() + self.create_sample_customer() def create_currency(self): """Add CHF as currency""" @@ -144,3 +145,26 @@ class Command(BaseCommand): # 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 diff --git a/uncloud_v3/app/migrations/0001_initial.py b/uncloud_v3/app/migrations/0001_initial.py index 3302d90..00974fe 100644 --- a/uncloud_v3/app/migrations/0001_initial.py +++ b/uncloud_v3/app/migrations/0001_initial.py @@ -1,8 +1,9 @@ -# Generated by Django 4.0 on 2022-01-16 16:44 +# Generated by Django 5.2.2 on 2025-06-17 03:21 -from django.db import migrations, models +import app.models import django.db.models.deletion import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): @@ -10,7 +11,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('uauth', '0001_initial'), ] operations = [ @@ -24,34 +24,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,11 +62,23 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='ResourceOrder', + name='OneTimePrice', 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')), + ('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( @@ -74,25 +86,8 @@ class Migration(migrations.Migration): 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=[ @@ -100,16 +95,51 @@ class Migration(migrations.Migration): ('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')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.customer')), + ('product', models.ManyToManyField(blank=True, to='app.productorder')), ], ), migrations.CreateModel( - name='OneTimePrice', + 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()), - ('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')), + ('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'), + ), ] diff --git a/uncloud_v3/app/migrations/0002_resource_onetime_price.py b/uncloud_v3/app/migrations/0002_resource_onetime_price.py deleted file mode 100644 index 5350a89..0000000 --- a/uncloud_v3/app/migrations/0002_resource_onetime_price.py +++ /dev/null @@ -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'), - ), - ] diff --git a/uncloud_v3/app/migrations/0002_uncloudconfiguration_bill_billlineitem.py b/uncloud_v3/app/migrations/0002_uncloudconfiguration_bill_billlineitem.py new file mode 100644 index 0000000..d426dca --- /dev/null +++ b/uncloud_v3/app/migrations/0002_uncloudconfiguration_bill_billlineitem.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.2 on 2025-06-17 04:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UncloudConfiguration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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='Bill', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('period_start', models.DateTimeField()), + ('period_end', models.DateTimeField()), + ('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='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')), + ], + ), + ] diff --git a/uncloud_v3/app/migrations/0003_remove_resource_onetime_price_resource_onetime_price.py b/uncloud_v3/app/migrations/0003_remove_resource_onetime_price_resource_onetime_price.py deleted file mode 100644 index ccdca59..0000000 --- a/uncloud_v3/app/migrations/0003_remove_resource_onetime_price_resource_onetime_price.py +++ /dev/null @@ -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'), - ), - ] diff --git a/uncloud_v3/app/migrations/0004_alter_onetimeprice_options_and_more.py b/uncloud_v3/app/migrations/0004_alter_onetimeprice_options_and_more.py deleted file mode 100644 index 7efe960..0000000 --- a/uncloud_v3/app/migrations/0004_alter_onetimeprice_options_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/uncloud_v3/app/migrations/0005_alter_productorder_timeframe_and_more.py b/uncloud_v3/app/migrations/0005_alter_productorder_timeframe_and_more.py deleted file mode 100644 index 713ad20..0000000 --- a/uncloud_v3/app/migrations/0005_alter_productorder_timeframe_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.1.6 on 2023-02-05 20:21 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('app', '0004_alter_onetimeprice_options_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='productorder', - name='timeframe', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'), - ), - migrations.AlterField( - model_name='resource', - name='onetime_price', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.onetimeprice'), - ), - ] diff --git a/uncloud_v3/app/migrations/0006_resource_default_value.py b/uncloud_v3/app/migrations/0006_resource_default_value.py deleted file mode 100644 index f628c66..0000000 --- a/uncloud_v3/app/migrations/0006_resource_default_value.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.2 on 2025-03-08 02:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('app', '0005_alter_productorder_timeframe_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='resource', - name='default_value', - field=models.FloatField(blank=True, null=True), - ), - ] diff --git a/uncloud_v3/app/migrations/0007_alter_product_slug_alter_resource_slug.py b/uncloud_v3/app/migrations/0007_alter_product_slug_alter_resource_slug.py deleted file mode 100644 index 2ec2b5d..0000000 --- a/uncloud_v3/app/migrations/0007_alter_product_slug_alter_resource_slug.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.0.2 on 2025-03-08 03:31 - -import app.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('app', '0006_resource_default_value'), - ] - - operations = [ - migrations.AlterField( - model_name='product', - name='slug', - field=models.SlugField(null=True, unique=True, validators=[app.models.validate_name_not_product]), - ), - migrations.AlterField( - model_name='resource', - name='slug', - field=models.SlugField(null=True, unique=True, validators=[app.models.validate_name_not_product]), - ), - ] diff --git a/uncloud_v3/app/models.py b/uncloud_v3/app/models.py index daf3d63..94657c5 100644 --- a/uncloud_v3/app/models.py +++ b/uncloud_v3/app/models.py @@ -5,6 +5,7 @@ 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): """ @@ -176,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) @@ -187,4 +307,88 @@ 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) + + # Single date - automatically converted to start/end of day + billing_date = models.DateField(default=timezone.now) + + # Automatically calculated time period this bill covers + 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 billing_date + if self.billing_date: + # Start of day (00:00:00) + self.period_start = timezone.make_aware( + timezone.datetime.combine(self.billing_date, timezone.datetime.min.time()) + ) + # End of day (23:59:59.999999) + self.period_end = timezone.make_aware( + timezone.datetime.combine(self.billing_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) + + def __str__(self): + return f"Bill {self.bill_number} for {self.customer} ({self.billing_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}" diff --git a/uncloud_v3/app/services.py b/uncloud_v3/app/services.py index 1db25e1..a25e048 100644 --- a/uncloud_v3/app/services.py +++ b/uncloud_v3/app/services.py @@ -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,10 +24,93 @@ 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 + 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, billing_date, created_by, include_orders=None, notes=""): + """ + Create a bill for a customer for a specific date (covers full day) + + Args: + customer: Customer object + billing_date: date - the date this bill covers + 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) + bill = Bill.objects.create( + customer=customer, + billing_date=billing_date, + created_by=created_by, + due_date=billing_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 From 0d88ef7f32f60a73fb4d417b7f0cfb2465cfe797 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Jun 2025 13:46:39 +0900 Subject: [PATCH 15/16] aider gitignore Signed-off-by: Nico Schottelius --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5c039d9..15db01a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ dist/ *.sqlite3 .DS_Store static/CACHE/ +.aider* From 56a357268059c20481683f642490898242373d25 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Jun 2025 14:03:09 +0900 Subject: [PATCH 16/16] Extend billing to a have a preiod --- uncloud_v3/app/admin.py | 19 +++- uncloud_v3/app/forms.py | 25 ++++- .../app/management/commands/test-data.py | 27 ++++++ uncloud_v3/app/migrations/0001_initial.py | 47 +++++++++- ..._uncloudconfiguration_bill_billlineitem.py | 57 ------------ uncloud_v3/app/models.py | 92 +++++++++++-------- uncloud_v3/app/services.py | 14 +-- uncloud_v3/bin/reset-migrations.sh | 7 ++ 8 files changed, 182 insertions(+), 106 deletions(-) delete mode 100644 uncloud_v3/app/migrations/0002_uncloudconfiguration_bill_billlineitem.py create mode 100644 uncloud_v3/bin/reset-migrations.sh diff --git a/uncloud_v3/app/admin.py b/uncloud_v3/app/admin.py index f404f0b..b22f4fd 100644 --- a/uncloud_v3/app/admin.py +++ b/uncloud_v3/app/admin.py @@ -23,6 +23,24 @@ class CustomerAdmin(admin.ModelAdmin): 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, OneTimePrice, @@ -33,7 +51,6 @@ for m in [ ResourceOrder, TimeFrame, Order, - Bill, BillLineItem, ]: admin.site.register(m) diff --git a/uncloud_v3/app/forms.py b/uncloud_v3/app/forms.py index b6ee64b..63e47ab 100644 --- a/uncloud_v3/app/forms.py +++ b/uncloud_v3/app/forms.py @@ -55,17 +55,23 @@ class CreateBillForm(forms.Form): empty_label="Select a customer" ) - billing_date = forms.DateField( + period_start_date = forms.DateField( widget=forms.DateInput(attrs={'type': 'date'}), - initial=timezone.now().date(), - help_text="The date this bill covers (automatically uses start/end of day)" + 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 day" + help_text="Leave empty to include all active orders for the period" ) notes = forms.CharField( @@ -84,3 +90,14 @@ class CreateBillForm(forms.Form): 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 diff --git a/uncloud_v3/app/management/commands/test-data.py b/uncloud_v3/app/management/commands/test-data.py index 1171cf0..52c6435 100644 --- a/uncloud_v3/app/management/commands/test-data.py +++ b/uncloud_v3/app/management/commands/test-data.py @@ -1,4 +1,5 @@ from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model from app.models import * @@ -16,6 +17,7 @@ class Command(BaseCommand): self.create_resources(currency) self.create_products() self.create_sample_customer() + self.create_admin_user() def create_currency(self): """Add CHF as currency""" @@ -168,3 +170,28 @@ class Command(BaseCommand): 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 diff --git a/uncloud_v3/app/migrations/0001_initial.py b/uncloud_v3/app/migrations/0001_initial.py index 00974fe..f8d8646 100644 --- a/uncloud_v3/app/migrations/0001_initial.py +++ b/uncloud_v3/app/migrations/0001_initial.py @@ -1,8 +1,9 @@ -# Generated by Django 5.2.2 on 2025-06-17 03:21 +# Generated by Django 5.2.2 on 2025-06-17 04:58 import app.models import django.db.models.deletion import django.utils.timezone +from django.conf import settings from django.db import migrations, models @@ -11,6 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -61,6 +63,38 @@ class Migration(migrations.Migration): ('seconds', models.IntegerField(blank=True, null=True)), ], ), + migrations.CreateModel( + name='UncloudConfiguration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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='Bill', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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', fields=[ @@ -99,6 +133,17 @@ class Migration(migrations.Migration): ('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=[ diff --git a/uncloud_v3/app/migrations/0002_uncloudconfiguration_bill_billlineitem.py b/uncloud_v3/app/migrations/0002_uncloudconfiguration_bill_billlineitem.py deleted file mode 100644 index d426dca..0000000 --- a/uncloud_v3/app/migrations/0002_uncloudconfiguration_bill_billlineitem.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 5.2.2 on 2025-06-17 04:36 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('app', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='UncloudConfiguration', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('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='Bill', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('period_start', models.DateTimeField()), - ('period_end', models.DateTimeField()), - ('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='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')), - ], - ), - ] diff --git a/uncloud_v3/app/models.py b/uncloud_v3/app/models.py index 94657c5..62c0520 100644 --- a/uncloud_v3/app/models.py +++ b/uncloud_v3/app/models.py @@ -187,15 +187,15 @@ class UncloudConfiguration(models.Model): 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: @@ -205,36 +205,36 @@ class UncloudConfiguration(models.Model): 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): """ @@ -248,7 +248,7 @@ class UncloudConfiguration(models.Model): } ) return config - + def __str__(self): return f"Uncloud Config - IPv6 Prefix: {self.ipv6_billing_prefix}, Counter: {self.billing_counter}" @@ -316,19 +316,20 @@ 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() - # Single date - automatically converted to start/end of day - billing_date = models.DateField(default=timezone.now) - - # Automatically calculated time period this bill covers + # 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'), @@ -338,35 +339,52 @@ class Bill(models.Model): ('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 billing_date - if self.billing_date: + # 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.billing_date, timezone.datetime.min.time()) - ) - # End of day (23:59:59.999999) - self.period_end = timezone.make_aware( - timezone.datetime.combine(self.billing_date, timezone.datetime.max.time()) + 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.billing_date})" + return f"Bill {self.bill_number} for {self.customer} ({self.period_start_date} to {self.period_end_date})" class BillLineItem(models.Model): @@ -374,21 +392,21 @@ 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}" diff --git a/uncloud_v3/app/services.py b/uncloud_v3/app/services.py index a25e048..c33dbe9 100644 --- a/uncloud_v3/app/services.py +++ b/uncloud_v3/app/services.py @@ -33,24 +33,26 @@ def order_product(product, timeframe, formdata): # resources = models.ManyToManyField(ResourceOrder) -def create_bill_for_customer(customer, billing_date, created_by, include_orders=None, notes=""): +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 (covers full day) + Create a bill for a customer for a specific date range (covers full days) Args: customer: Customer object - billing_date: date - the date this bill covers + 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) + # Create the bill (period_start/end will be auto-calculated from dates) bill = Bill.objects.create( customer=customer, - billing_date=billing_date, + period_start_date=period_start_date, + period_end_date=period_end_date, created_by=created_by, - due_date=billing_date + timedelta(days=30), # Default 30 days payment term + due_date=period_end_date + timedelta(days=30), # Default 30 days payment term ) # Get the auto-calculated period times diff --git a/uncloud_v3/bin/reset-migrations.sh b/uncloud_v3/bin/reset-migrations.sh new file mode 100644 index 0000000..bf507e8 --- /dev/null +++ b/uncloud_v3/bin/reset-migrations.sh @@ -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