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* 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. - - -[](https://code.ungleich.ch/uncloud/uncloud/commits/master) -[](https://code.ungleich.ch/uncloud/uncloud/commits/master) - -## Useful commands - -* `./manage.py import-vat-rates path/to/csv` -* `./manage.py createsuperuser` - -## Development setup - -Install system dependencies: - -* On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium` -* sudo apt-get install libpq-dev python-dev libxml2-dev libxslt1-dev libldap2-dev libsasl2-dev libffi-dev -* On Archlinux, [libldap24](https://aur.archlinux.org/packages/libldap24) is needed - - -NOTE: you will need to configure a LDAP server and credentials for authentication. See `uncloud/settings.py`. - -``` -# Initialize virtualenv. -» virtualenv .venv -Using base prefix '/usr' -New python executable in /home/fnux/Workspace/ungleich/uncloud/uncloud/.venv/bin/python3 -Also creating executable in /home/fnux/Workspace/ungleich/uncloud/uncloud/.venv/bin/python -Installing setuptools, pip, wheel... -done. - -# Enter virtualenv. -» source .venv/bin/activate - -# Install dependencies. -» pip install -r requirements.txt -[...] - -# Run migrations. -» ./manage.py migrate -Operations to perform: - Apply all migrations: admin, auth, contenttypes, opennebula, sessions, uncloud_auth, uncloud_net, uncloud_pay, uncloud_service, uncloud_vm -Running migrations: - [...] - -# Run webserver. -» ./manage.py runserver -Watching for file changes with StatReloader -Performing system checks... - -System check identified no issues (0 silenced). -May 07, 2020 - 10:17:08 -Django version 3.0.6, using settings 'uncloud.settings' -Starting development server at http://127.0.0.1:8000/ -Quit the server with CONTROL-C. -``` -### Run Background Job Queue -We use Django Q to handle the asynchronous code and Background Cron jobs -To start the workers make sure first that Redis or the Django Q broker is working and you can edit it's settings in the settings file. -``` -./manage.py qcluster -``` - -### Note on PGSQL - -If you want to use Postgres: - -* Install on configure PGSQL on your base system. -* OR use a container! `podman run --rm -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -it postgres:latest` +Ignore most of it, checkout uncloud_v3. 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/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 diff --git a/uncloud_v3/Dockerfile b/uncloud_v3/Dockerfile index c953d76..9217342 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.13.4-alpine3.22 WORKDIR /usr/src/app diff --git a/uncloud_v3/Makefile b/uncloud_v3/Makefile index cadb844..264524b 100644 --- a/uncloud_v3/Makefile +++ b/uncloud_v3/Makefile @@ -1,7 +1,16 @@ -all: requirements +IMAGE_NAME=harbor.k8s.ungleich.ch/ungleich-public/uncloud +IMAGE=$(IMAGE_NAME):$$(git describe --dirty) + +all: requirements build + +build: + sh -c 'docker build -t $(IMAGE) .' + +pub: build + docker push $(IMAGE) run: requirements - . ./env && python manage.py runserver + . ./venv/bin/activate && python manage.py runserver requirements: venv . ./venv/bin/activate && pip install -r requirements.txt diff --git a/uncloud_v3/README-2025.org b/uncloud_v3/README-2025.org new file mode 100644 index 0000000..6bfa83f --- /dev/null +++ b/uncloud_v3/README-2025.org @@ -0,0 +1,45 @@ +* Generally to be done for prod [44%] +** TODO Add description to product + - Maybe markdown later +** TODO Link orders to users +** TODO Maybe i18n on everything +** PROGRESS Re-understand get_context_data + - gets additional context such as other models / related models + - Unsure when the context data is being used. + - Usually used in the Detailview of the thing + - so likely referenced by a template or View +** TODO Prevent a product or resource to be named "product" + Because of initial['product'] = self.kwargs['product'] + + + +** DONE Add default values to resources +CLOSED: [2025-03-08 Sat 12:27] +** DONE Re-understand get_form_kwargs +CLOSED: [2025-03-08 Sat 11:48] + - Build the keyword arguments required to instantiate the form. + - Kinda defining the keys needed + + https://docs.djangoproject.com/en/5.1/ref/class-based-views/mixins-editing/ +** DONE Re-understand get_initial +CLOSED: [2025-03-08 Sat 11:48] + - Kinda getting the (initial) values for the keys + - populates form values + " Retrieve initial data for the form. By default, returns a + copy of initial." + https://docs.djangoproject.com/en/5.1/ref/class-based-views/mixins-editing/ + +** DONE Why are (one time) prices separate from resources? +CLOSED: [2025-03-08 Sat 11:31] + A: because duration prices need to be separate. + + - onetime price seems to be reasonable to be inside resource + - Price per timeframe must be separate + - Thus onetime price was also added separately +* Bills [0%] +** TODO Show bills +** TODO Allow to create / send bills +** PROGRESS Add customer/client +* OIDC integration [%] +** Write code +** Test with authentik diff --git a/uncloud_v3/README.md b/uncloud_v3/README.md index 1d53093..17a44af 100644 --- a/uncloud_v3/README.md +++ b/uncloud_v3/README.md @@ -12,9 +12,37 @@ machine. Use `kubectl get nodes` to verify minikube is up and running. * `SECRET_KEY` * `DEBUG` * `DATABASE` + * Should be: POSTGRES_HOST, POSTGRES_DB, POSTGRES_USER, POSTRES_PASSWORD + + +## Objective for v1.0 + +* I can order a Nextcloud instance with a name, it gets created and + billed + +### Ordering Nextcloud + +* Being able to specify the details +* Being able to enter a domain name (pre-selected list) + +### Logging in + +* LDAP support + +### Creating it + +* Writing yaml file with argocd specification + +### Billing it + +* Handling the time frames +* Handling stripe +* Handling CH VAT +* Handling EU VAT ## Versions + #### Future (unplanned) * When/where to add timeframe constraints @@ -35,11 +63,14 @@ machine. Use `kubectl get nodes` to verify minikube is up and running. * yes: ModelAdmin.formfield_for_manytomany(db_field, request, **kwargs)¶ * resources should have a slug * can be used as an identifier and non unique names +* Execute collectstatic for docker +* OIDC / use authentik #### 3.1 (validation release, planned) * Ensure that one resource cannot have multiple price_per_timeframe of the same timeframe +* Add wireguard config support #### 3.0.2 (planned) @@ -48,13 +79,15 @@ machine. Use `kubectl get nodes` to verify minikube is up and running. #### 3.0.1 (planned) +NEXT STEP: CREATE THE ORDER AND RESOURCE ORDER OBJECTS + * Show products [done] * Link to ProductOrderForm [done] * Find suitable timeframes for a product [done] * Continue to resources / add resources * Need to list resources [done] * Need to create manytomany relations for each resource resoluting - in ResourceOrders1 + in ResourceOrders * Need to pass in the price for the selected timeframe [done] * On submit * Create ProductOrder diff --git a/uncloud_v3/app/admin.py b/uncloud_v3/app/admin.py index 27ac5b2..b22f4fd 100644 --- a/uncloud_v3/app/admin.py +++ b/uncloud_v3/app/admin.py @@ -2,10 +2,47 @@ from django.contrib import admin from .models import * +@admin.register(UncloudConfiguration) +class UncloudConfigurationAdmin(admin.ModelAdmin): + list_display = ['ipv6_billing_prefix', 'billing_counter'] + fields = ['ipv6_billing_prefix', 'billing_counter'] + + def has_add_permission(self, request): + # Only allow one configuration instance + return not UncloudConfiguration.objects.exists() + + def has_delete_permission(self, request, obj=None): + # Don't allow deletion of configuration + return False + + +@admin.register(Customer) +class CustomerAdmin(admin.ModelAdmin): + list_display = ['email', 'full_name', 'company_name', 'created_at'] + list_filter = ['created_at', 'country'] + search_fields = ['email', 'first_name', 'last_name', 'company_name'] + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(Bill) +class BillAdmin(admin.ModelAdmin): + list_display = ['bill_number', 'customer', 'period_start_date', 'period_end_date', 'status', 'total_amount', 'created_at'] + list_filter = ['status', 'created_at', 'period_start_date'] + search_fields = ['bill_number', 'customer__email', 'customer__company_name'] + readonly_fields = ['bill_number', 'created_at', 'period_start', 'period_end', 'total_amount'] + date_hierarchy = 'created_at' + + def get_readonly_fields(self, request, obj=None): + """Make period dates readonly if bill is not in draft status""" + readonly_fields = list(self.readonly_fields) + + if obj and not obj.can_edit_dates: + readonly_fields.extend(['period_start_date', 'period_end_date']) + + return readonly_fields for m in [ Currency, - Order, OneTimePrice, PricePerTime, Product, @@ -13,5 +50,7 @@ for m in [ Resource, ResourceOrder, TimeFrame, + Order, + BillLineItem, ]: admin.site.register(m) diff --git a/uncloud_v3/app/forms.py b/uncloud_v3/app/forms.py index af42715..63e47ab 100644 --- a/uncloud_v3/app/forms.py +++ b/uncloud_v3/app/forms.py @@ -1,4 +1,9 @@ from django import forms +from django.forms import NumberInput +from django.utils import timezone +from datetime import datetime, timedelta +from .models import Customer, Order + class ProductOneTimeOrderForm(forms.Form): """ @@ -10,9 +15,23 @@ class ProductOneTimeOrderForm(forms.Form): def __init__(self, resources, *args, **kwargs): super().__init__(*args, **kwargs) for res in resources: - print(res) field_name = f"{res.slug}" - self.fields[field_name] = forms.FloatField(required=True, label=res.name) + self.fields[field_name] = forms.FloatField( + required=True, + label=res.name, + min_value=res.minimum_units, + max_value=res.maximum_units, + widget=NumberInput(attrs={"step": res.step_size})) + + # if res.minimum_units < res.maximum_units: + # self.fields[field_name] = forms.FloatField( + # required=True, + # label=res.name, + # min_value=res.minimum_units, + # max_value=res.maximum_units, + # widget=NumberInput(attrs={"step": res.step_size})) + # else: + # self.fields[field_name] = forms.FloatField(widget=forms.HiddenInput(attrs={'value': res.minimum_units})) def clean(self): cleaned_data = super().clean() @@ -21,7 +40,64 @@ class ProductOneTimeOrderForm(forms.Form): class ProductOrderForm(ProductOneTimeOrderForm): """ - For recurring products (might also have OneTime items + For recurring products (might also have OneTime items) """ timeframe = forms.SlugField(required=False, disabled=True) + + +class CreateBillForm(forms.Form): + """ + Form for staff to create bills for customers + """ + customer = forms.ModelChoiceField( + queryset=Customer.objects.all(), + empty_label="Select a customer" + ) + + period_start_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date'}), + initial=lambda: timezone.now().date().replace(day=1), # First day of current month + help_text="Start date of billing period (time automatically set to 00:00:00)" + ) + + period_end_date = forms.DateField( + widget=forms.DateInput(attrs={'type': 'date'}), + initial=lambda: (timezone.now().date().replace(day=1) + timedelta(days=32)).replace(day=1) - timedelta(days=1), # Last day of current month + help_text="End date of billing period (time automatically set to 23:59:59)" + ) + + include_orders = forms.ModelMultipleChoiceField( + queryset=Order.objects.none(), + required=False, + widget=forms.CheckboxSelectMultiple, + help_text="Leave empty to include all active orders for the period" + ) + + notes = forms.CharField( + widget=forms.Textarea(attrs={'rows': 3}), + required=False, + help_text="Optional notes for this bill" + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # If customer is selected, filter orders + if 'customer' in self.data: + try: + customer_id = int(self.data.get('customer')) + self.fields['include_orders'].queryset = Order.objects.filter(customer_id=customer_id) + except (ValueError, TypeError): + pass + + def clean(self): + cleaned_data = super().clean() + period_start_date = cleaned_data.get('period_start_date') + period_end_date = cleaned_data.get('period_end_date') + + if period_start_date and period_end_date: + if period_start_date > period_end_date: + raise forms.ValidationError("Period start date must be before or equal to period end date") + + return cleaned_data diff --git a/uncloud_v3/app/management/commands/test-data.py b/uncloud_v3/app/management/commands/test-data.py index 7e01b28..52c6435 100644 --- a/uncloud_v3/app/management/commands/test-data.py +++ b/uncloud_v3/app/management/commands/test-data.py @@ -1,75 +1,197 @@ from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model from app.models import * class Command(BaseCommand): help = 'Add test data' -# def add_arguments(self, parser): + def add_arguments(self, parser): #parser.add_argument('--username', type=str, required=True) + pass def handle(self, *args, **options): - currency, created = Currency.objects.get_or_create(defaults= - { - "slug": "CHF", - "name": "Swiss Franc", - "short_name": "CHF" - }) - for timeframe in [ (3600, "1 hour", "1-hour"), - (86400, "1 day", "1-day"), - (7*86400, "7 days", "7-days"), - (30*86400, "30 days", "30-days"), - (365*86400, "365 days", "365 days") ]: - TimeFrame.objects.get_or_create(slug=timeframe[2], - defaults= - { - "name": timeframe[1], - "seconds": timeframe[0] - }) + currency = self.create_currency() + self.create_timeframes() + self.create_prices_per_time(currency) + self.create_resources(currency) + self.create_products() + self.create_sample_customer() + self.create_admin_user() - for ppt in [ - ("1-day", 1, currency), - ("1-day", 2, currency), - ("30-days", 10, currency), # Nextcloud - ("30-days", 15, currency), # Gitea - ("30-days", 35, currency), # Matrix - ("30-days", 29, currency), - ("30-days", 55, currency) ]: - tf = TimeFrame.objects.get(slug=ppt[0]) + def create_currency(self): + """Add CHF as currency""" + currency, created = Currency.objects.get_or_create( + slug="CHF", + defaults={ + "name": "Swiss Franc", + "short_name": "CHF" + } + ) + return currency - PricePerTime.objects.get_or_create(timeframe=tf, - value=ppt[1], - defaults= - { - "currency": currency - }) + def create_timeframes(self): + """Add standard timeframes""" + timeframes_data = [ + (3600, "1 hour", "1-hour"), + (86400, "1 day", "1-day"), + (7*86400, "7 days", "7-days"), + (30*86400, "30 days", "30-days"), + (365*86400, "365 days", "365 days") + ] - for res in [ - ("cpu-1", "CPU", "Core(s)", None, None), - ("cpu-min-max", "CPU", "Core(s)", 1, 20), - ("ram-1", "RAM", "GB", None, None), - ("ram-min-max", "RAM", "GB", 1, 200), - ("matrix-maintenance", "Matrix Maintenance Fee", "", 1, 1), - ("nextcloud-maintenance", "Nextcloud Maintenance Fee", "", 1, 1), - ("gitea-maintenance", "Gitea Maintenance Fee", "", 1, 1), + for seconds, name, slug in timeframes_data: + TimeFrame.objects.get_or_create( + slug=slug, + defaults={ + "name": name, + "seconds": seconds + } + ) - ]: - Resource.objects.get_or_create(slug=res[0], - defaults= - { - "name": res[1], - "unit": res[2], - "minimum_units": res[3], - "maximum_units": res[4] - }) - # Link to PPT -- later - # for ppt_res in res[5]: - # ppt = PricePerTime.objects.get( + def create_prices_per_time(self, currency): + """Add typical prices per timeframe""" + prices_data = [ + ("1-day", 1), + ("1-day", 2), + ("30-days", 2), # HDD 100 GB + ("30-days", 3), # CPU + ("30-days", 3.5), # SSD Storage + ("30-days", 4), # RAM + ("30-days", 10), # Nextcloud + ("30-days", 15), # Gitea + ("30-days", 35), # Matrix + ("30-days", 29), + ("30-days", 55) + ] + for timeframe_slug, value in prices_data: + tf = TimeFrame.objects.get(slug=timeframe_slug) + PricePerTime.objects.get_or_create( + timeframe=tf, + value=value, + defaults={ + "currency": currency + } + ) - for product in [ - ("matrix", "Matrix"), - ("nextcloud", "Nextcloud"), - ("gitea", "Gitea") ]: - Product.objects.get_or_create(slug=product[0], - defaults = { "name": product[1] }) + def create_resources(self, currency): + """Add typical resources""" + tf_30d = TimeFrame.objects.get(slug='30-days') + + resources_data = [ + # (slug, name, description, min, max, step_size, price_per_30days) + ("cpu-1", "CPU", "Core(s)", None, None, 0.5, 3), + ("cpu-min-max", "CPU", "Core(s)", 1, 20, 0.5, 3), + ("ram-1", "RAM", "GB", None, None, 0.5, 4), + ("ram-min-max", "RAM", "GB", 1, 200, 0.5, 4), + ("storage-db", "Database-Storage", "GB", 10, None, 10, 3.5), + ("storage-ssd", "SSD-Storage", "GB", 10, None, 10, 3.5), + ("storage-hdd", "HDD-Storage", "GB", 100, None, 100, 2), + ("matrix-maintenance", "Matrix Maintenance Fee", "", 1, 1, 1, 35), + ("nextcloud-maintenance", "Nextcloud Maintenance Fee", "", 1, 1, 1, 10), + ("gitea-maintenance", "Gitea Maintenance Fee", "", 1, 1, 1, 15), + ] + + for slug, name, unit, min_units, max_units, step_size, price in resources_data: + resource, created = Resource.objects.get_or_create( + slug=slug, + defaults={ + "name": name, + "unit": unit, + "minimum_units": min_units, + "maximum_units": max_units, + "step_size": step_size + } + ) + + # If price is given, assign it + if price: + try: + ppt = PricePerTime.objects.get(timeframe=tf_30d, value=price) + resource.price_per_time.add(ppt) + except PricePerTime.DoesNotExist: + print(f"Warning: PricePerTime with value {price} for 30-days timeframe does not exist. Skipping price assignment for resource {slug}.") + + def create_products(self): + """Add test products""" + tf_30d = TimeFrame.objects.get(slug='30-days') + + products_data = [ + ("matrix", "Matrix"), + ("nextcloud", "Nextcloud"), + ("gitea", "Gitea") + ] + + required_resources = [ + "cpu-min-max", + "ram-min-max", + "storage-db", + "storage-hdd" + ] + + for slug, name in products_data: + product, created = Product.objects.get_or_create( + slug=slug, + defaults={"name": name} + ) + + # Add required resources + for resource_slug in required_resources: + print(f"Adding {resource_slug} to {product}") + product.resources.add(Resource.objects.get(slug=resource_slug)) + + # Add maintenance resource specific to this product + maintenance_resource_slug = f"{slug}-maintenance" + product.resources.add(Resource.objects.get(slug=maintenance_resource_slug)) + + # Every test product can be bought for the 30d timeframe + product.timeframes.add(tf_30d) + + def create_sample_customer(self): + """Add a sample customer""" + customer, created = Customer.objects.get_or_create( + email='customer@ungleich.ch', + defaults={ + 'first_name': 'Ungleich', + 'last_name': 'Customer', + 'company_name': 'ungleich customer', + 'phone': '+41 44 534 66 22', + 'address_line1': 'Technoparkstrasse 1', + 'city': 'Zürich', + 'postal_code': '8005', + 'country': 'Switzerland' + } + ) + + if created: + print(f"Created sample customer: {customer}") + else: + print(f"Sample customer already exists: {customer}") + + return customer + + def create_admin_user(self): + """Add an admin superuser""" + User = get_user_model() + + admin_user, created = User.objects.get_or_create( + username='admin', + defaults={ + 'email': 'admin@uncloud.example.com', + 'first_name': 'Admin', + 'last_name': 'User', + 'is_staff': True, + 'is_superuser': True, + 'is_active': True + } + ) + + if created: + admin_user.set_password('veryinsecure') + admin_user.save() + print(f"Created admin superuser: {admin_user.username}") + else: + print(f"Admin user already exists: {admin_user.username}") + + return admin_user diff --git a/uncloud_v3/app/migrations/0001_initial.py b/uncloud_v3/app/migrations/0001_initial.py index 3302d90..f8d8646 100644 --- a/uncloud_v3/app/migrations/0001_initial.py +++ b/uncloud_v3/app/migrations/0001_initial.py @@ -1,8 +1,10 @@ -# Generated by Django 4.0 on 2022-01-16 16:44 +# Generated by Django 5.2.2 on 2025-06-17 04:58 -from django.db import migrations, models +import app.models import django.db.models.deletion import django.utils.timezone +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): @@ -10,7 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('uauth', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -24,34 +26,34 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='PricePerTime', + name='Customer', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('value', models.FloatField()), - ('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')), + ('email', models.EmailField(max_length=254, unique=True)), + ('first_name', models.CharField(blank=True, max_length=150)), + ('last_name', models.CharField(blank=True, max_length=150)), + ('company_name', models.CharField(blank=True, max_length=255)), + ('phone', models.CharField(blank=True, max_length=20)), + ('address_line1', models.CharField(blank=True, max_length=255)), + ('address_line2', models.CharField(blank=True, max_length=255)), + ('city', models.CharField(blank=True, max_length=100)), + ('postal_code', models.CharField(blank=True, max_length=20)), + ('country', models.CharField(blank=True, max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], + options={ + 'ordering': ['email'], + }, ), migrations.CreateModel( name='Product', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('slug', models.SlugField(null=True, unique=True)), + ('slug', models.SlugField(null=True, unique=True, validators=[app.models.validate_name_not_product])), ('name', models.CharField(max_length=128, unique=True)), ], ), - migrations.CreateModel( - name='Resource', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('slug', models.SlugField(null=True, unique=True)), - ('name', models.CharField(max_length=128)), - ('unit', models.CharField(max_length=128)), - ('minimum_units', models.FloatField(blank=True, null=True)), - ('maximum_units', models.FloatField(blank=True, null=True)), - ('step_size', models.FloatField(default=1)), - ('price_per_time', models.ManyToManyField(blank=True, to='app.PricePerTime')), - ], - ), migrations.CreateModel( name='TimeFrame', fields=[ @@ -62,47 +64,36 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='ResourceOrder', + name='UncloudConfiguration', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('value', models.FloatField()), - ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.resource')), + ('ipv6_billing_prefix', models.CharField(default='2001:db8::/64', help_text='IPv6 prefix for generating billing numbers (e.g., 2001:db8::/64)', max_length=39)), + ('billing_counter', models.BigIntegerField(default=1)), ], + options={ + 'verbose_name': 'Uncloud Configuration', + 'verbose_name_plural': 'Uncloud Configuration', + }, ), migrations.CreateModel( - name='ProductOrder', + name='Bill', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.product')), - ('resources', models.ManyToManyField(to='app.ResourceOrder')), - ('timeframe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe')), - ], - ), - migrations.AddField( - model_name='product', - name='resources', - field=models.ManyToManyField(blank=True, to='app.Resource'), - ), - migrations.AddField( - model_name='product', - name='timeframes', - field=models.ManyToManyField(blank=True, to='app.TimeFrame'), - ), - migrations.AddField( - model_name='pricepertime', - name='timeframe', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'), - ), - migrations.CreateModel( - name='Order', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_date', models.DateTimeField(auto_now_add=True)), - ('starting_date', models.DateTimeField(default=django.utils.timezone.now)), - ('ending_date', models.DateTimeField(blank=True, null=True)), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uauth.user')), - ('product', models.ManyToManyField(blank=True, to='app.ProductOrder')), + ('period_start_date', models.DateField()), + ('period_end_date', models.DateField()), + ('period_start', models.DateTimeField(editable=False)), + ('period_end', models.DateTimeField(editable=False)), + ('bill_number', models.CharField(blank=True, max_length=39, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent'), ('paid', 'Paid'), ('overdue', 'Overdue'), ('cancelled', 'Cancelled')], default='draft', max_length=20)), + ('due_date', models.DateField()), + ('paid_date', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.customer')), ], + options={ + 'ordering': ['-created_at'], + }, ), migrations.CreateModel( name='OneTimePrice', @@ -111,5 +102,89 @@ class Migration(migrations.Migration): ('value', models.FloatField()), ('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')), ], + options={ + 'ordering': ('value',), + }, + ), + migrations.CreateModel( + name='PricePerTime', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.FloatField()), + ('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')), + ('timeframe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe')), + ], + ), + migrations.CreateModel( + name='ProductOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.product')), + ], + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField(auto_now_add=True)), + ('starting_date', models.DateTimeField(default=django.utils.timezone.now)), + ('ending_date', models.DateTimeField(blank=True, null=True)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.customer')), + ('product', models.ManyToManyField(blank=True, to='app.productorder')), + ], + ), + migrations.CreateModel( + name='BillLineItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=255)), + ('quantity', models.DecimalField(decimal_places=2, max_digits=10)), + ('unit_price', models.DecimalField(decimal_places=2, max_digits=10)), + ('bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='line_items', to='app.bill')), + ('product_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='app.productorder')), + ], + ), + migrations.CreateModel( + name='Resource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(null=True, unique=True, validators=[app.models.validate_name_not_product])), + ('name', models.CharField(max_length=128)), + ('unit', models.CharField(max_length=128)), + ('minimum_units', models.FloatField(blank=True, null=True)), + ('maximum_units', models.FloatField(blank=True, null=True)), + ('default_value', models.FloatField(blank=True, null=True)), + ('step_size', models.FloatField(default=1)), + ('onetime_price', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.onetimeprice')), + ('price_per_time', models.ManyToManyField(blank=True, to='app.pricepertime')), + ], + ), + migrations.AddField( + model_name='product', + name='resources', + field=models.ManyToManyField(blank=True, to='app.resource'), + ), + migrations.CreateModel( + name='ResourceOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.FloatField()), + ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.resource')), + ], + ), + migrations.AddField( + model_name='productorder', + name='resources', + field=models.ManyToManyField(to='app.resourceorder'), + ), + migrations.AddField( + model_name='productorder', + name='timeframe', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'), + ), + migrations.AddField( + model_name='product', + name='timeframes', + field=models.ManyToManyField(blank=True, to='app.timeframe'), ), ] 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/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/models.py b/uncloud_v3/app/models.py index 3ae8a56..62c0520 100644 --- a/uncloud_v3/app/models.py +++ b/uncloud_v3/app/models.py @@ -3,6 +3,21 @@ from django.contrib.auth import get_user_model from django.utils import timezone from django.urls import reverse from django.db.models import Q +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +import ipaddress + +def validate_name_not_product(value): + """ + We want to prevent overriding our own code. + So the hardcoded name "product" may not be used as a product or resource name + """ + + if value == "product": + raise ValidationError( + _("%(value)s is not allowed as the name"), + params={"value": value}, + ) class Currency(models.Model): slug = models.SlugField(null=True, unique=True) @@ -56,16 +71,19 @@ class PricePerTime(models.Model): return f"{self.value}{self.currency.short_name}/{self.timeframe}" class Resource(models.Model): - slug = models.SlugField(null=True, unique=True) # primary identifier + slug = models.SlugField(null=True, unique=True, validators=[validate_name_not_product]) # primary identifier name = models.CharField(max_length=128, unique=False) # CPU, RAM unit = models.CharField(max_length=128) # Count, GB minimum_units = models.FloatField(null=True, blank=True) # might have min maximum_units = models.FloatField(null=True, blank=True) # might have max + default_value = models.FloatField(null=True, blank=True) # default value to show step_size = models.FloatField(default=1) # step size price_per_time = models.ManyToManyField(PricePerTime, blank=True) - #onetime_price = models.ManyToManyField(OneTimePrice, blank=True) - onetime_price = models.ForeignKey(OneTimePrice, null=True, on_delete=models.CASCADE) + onetime_price = models.ForeignKey(OneTimePrice, + null=True, blank=True, + on_delete=models.CASCADE) + def __str__(self): if self.minimum_units: @@ -88,7 +106,7 @@ class Product(models.Model): Describes a product a user can buy """ - slug = models.SlugField(null=True, unique=True) + slug = models.SlugField(null=True, unique=True, validators=[validate_name_not_product]) name = models.CharField(max_length=128, unique=True) resources = models.ManyToManyField(Resource, blank=True) # List of REQUIRED resources @@ -159,10 +177,129 @@ class ProductOrder(models.Model): return txt +class UncloudConfiguration(models.Model): + """ + Global configuration for uncloud instance + """ + # IPv6 prefix for billing numbers + ipv6_billing_prefix = models.CharField( + max_length=39, # Max length for IPv6 address + default='2001:db8::/64', + help_text='IPv6 prefix for generating billing numbers (e.g., 2001:db8::/64)' + ) + + # Counter for billing numbers + billing_counter = models.BigIntegerField(default=1) + + # Singleton pattern - only one configuration should exist + class Meta: + verbose_name = 'Uncloud Configuration' + verbose_name_plural = 'Uncloud Configuration' + + def save(self, *args, **kwargs): + # Validate IPv6 prefix + try: + network = ipaddress.IPv6Network(self.ipv6_billing_prefix, strict=False) + # Ensure we have enough host bits for billing numbers + if network.prefixlen >= 128: + raise ValidationError("IPv6 prefix must allow for host addresses (prefix length < 128)") + except ipaddress.AddressValueError: + raise ValidationError("Invalid IPv6 prefix format") + + # Ensure only one configuration exists + if not self.pk and UncloudConfiguration.objects.exists(): + raise ValidationError("Only one configuration instance is allowed") + + super().save(*args, **kwargs) + + def get_next_billing_ipv6(self): + """ + Generate the next IPv6 billing address and increment counter + """ + try: + network = ipaddress.IPv6Network(self.ipv6_billing_prefix, strict=False) + + # Calculate the host address by adding the counter to the network address + host_address = network.network_address + self.billing_counter + + # Ensure we don't exceed the network range + if host_address not in network: + raise ValidationError(f"Billing counter {self.billing_counter} exceeds network range {self.ipv6_billing_prefix}") + + # Increment counter for next use + self.billing_counter += 1 + self.save() + + return str(host_address) + + except Exception as e: + raise ValidationError(f"Error generating IPv6 billing address: {str(e)}") + + @classmethod + def get_instance(cls): + """ + Get or create the singleton configuration instance + """ + config, created = cls.objects.get_or_create( + pk=1, + defaults={ + 'ipv6_billing_prefix': '2001:db8::/64', + 'billing_counter': 1 + } + ) + return config + + def __str__(self): + return f"Uncloud Config - IPv6 Prefix: {self.ipv6_billing_prefix}, Counter: {self.billing_counter}" + + +class Customer(models.Model): + """ + Customer model for tying orders to customers + Future: will be linked to authentik OIDC users + """ + email = models.EmailField(unique=True) + first_name = models.CharField(max_length=150, blank=True) + last_name = models.CharField(max_length=150, blank=True) + company_name = models.CharField(max_length=255, blank=True) + + # Contact information + phone = models.CharField(max_length=20, blank=True) + + # Address fields + address_line1 = models.CharField(max_length=255, blank=True) + address_line2 = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=100, blank=True) + postal_code = models.CharField(max_length=20, blank=True) + country = models.CharField(max_length=100, blank=True) + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Future: authentik integration + # authentik_user_id = models.CharField(max_length=255, blank=True, null=True, unique=True) + + class Meta: + ordering = ['email'] + + def __str__(self): + if self.company_name: + return f"{self.company_name} ({self.email})" + elif self.first_name or self.last_name: + return f"{self.first_name} {self.last_name} ({self.email})".strip() + else: + return self.email + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}".strip() class Order(models.Model): - owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False) + customer = models.ForeignKey('Customer', on_delete=models.CASCADE) + # Remove the owner field since we're using customer now + # owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False) creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField(default=timezone.now) @@ -170,4 +307,106 @@ class Order(models.Model): product = models.ManyToManyField(ProductOrder, blank=True) - #textconfigs = models.ManyToManyField(ResourceConfig) + def __str__(self): + return f"Order {self.id} for {self.customer}" + + +class Bill(models.Model): + """ + A bill for a customer covering a specific time period + """ + customer = models.ForeignKey('Customer', on_delete=models.CASCADE) + + # Date range for billing period (staff sets these) + period_start_date = models.DateField() + period_end_date = models.DateField() + + # Automatically calculated datetime period (start of day to end of day) + period_start = models.DateTimeField(editable=False) + period_end = models.DateTimeField(editable=False) + + # Bill metadata - IPv6 address as billing number + bill_number = models.CharField(max_length=39, unique=True, blank=True) # IPv6 max length + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey(get_user_model(), on_delete=models.PROTECT) + + # Bill status + STATUS_CHOICES = [ + ('draft', 'Draft'), + ('sent', 'Sent'), + ('paid', 'Paid'), + ('overdue', 'Overdue'), + ('cancelled', 'Cancelled'), + ] + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + + # Payment information + due_date = models.DateField() + paid_date = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + + def save(self, *args, **kwargs): + # Auto-calculate period_start and period_end from date fields + if self.period_start_date: + # Start of day (00:00:00) + self.period_start = timezone.make_aware( + timezone.datetime.combine(self.period_start_date, timezone.datetime.min.time()) + ) + + if self.period_end_date: + # End of day (23:59:59.999999) + self.period_end = timezone.make_aware( + timezone.datetime.combine(self.period_end_date, timezone.datetime.max.time()) + ) + + if not self.bill_number: + # Generate IPv6 billing number + config = UncloudConfiguration.get_instance() + self.bill_number = config.get_next_billing_ipv6() + + super().save(*args, **kwargs) + + @property + def total_amount(self): + """Calculate total amount from all line items""" + return sum(item.total for item in self.line_items.all()) + + @property + def period_days(self): + """Number of days this bill covers""" + return (self.period_end_date - self.period_start_date).days + 1 + + @property + def can_edit_dates(self): + """Check if billing dates can still be edited""" + return self.status == 'draft' + + def __str__(self): + return f"Bill {self.bill_number} for {self.customer} ({self.period_start_date} to {self.period_end_date})" + + +class BillLineItem(models.Model): + """ + Individual line items on a bill + """ + bill = models.ForeignKey(Bill, on_delete=models.CASCADE, related_name='line_items') + + # Description of the line item + description = models.CharField(max_length=255) + + # Quantity and pricing + quantity = models.DecimalField(max_digits=10, decimal_places=2) + unit_price = models.DecimalField(max_digits=10, decimal_places=2) + + # Optional: link to the product order that generated this line item + product_order = models.ForeignKey(ProductOrder, on_delete=models.SET_NULL, null=True, blank=True) + + # Calculated total (quantity * unit_price) + @property + def total(self): + return self.quantity * self.unit_price + + def __str__(self): + return f"{self.description} - {self.quantity} x {self.unit_price}" diff --git a/uncloud_v3/app/services.py b/uncloud_v3/app/services.py index ce98c66..c33dbe9 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,9 +24,95 @@ def order_product(product, timeframe, formdata): resource = get_object_or_404(Resource, slug=res) ro = ResourceOrder.objects.create(value=value, resource=resource) po.resources.add(ro) + return po # Ordering without a timeframe # if not timeframe: # product = models.ForeignKey(Product, on_delete=models.CASCADE) # timeframe = models.ForeignKey(TimeFrame, null=True, on_delete=models.CASCADE) # resources = models.ManyToManyField(ResourceOrder) + + +def create_bill_for_customer(customer, period_start_date, period_end_date, created_by, include_orders=None, notes=""): + """ + Create a bill for a customer for a specific date range (covers full days) + + Args: + customer: Customer object + period_start_date: date - start date of billing period + period_end_date: date - end date of billing period + created_by: User who is creating the bill + include_orders: list of Order IDs to include (if None, includes all active orders) + notes: optional notes for the bill + """ + + # Create the bill (period_start/end will be auto-calculated from dates) + bill = Bill.objects.create( + customer=customer, + period_start_date=period_start_date, + period_end_date=period_end_date, + created_by=created_by, + due_date=period_end_date + timedelta(days=30), # Default 30 days payment term + ) + + # Get the auto-calculated period times + period_start = bill.period_start + period_end = bill.period_end + + # Get orders to include + if include_orders: + orders = Order.objects.filter(id__in=include_orders, customer=customer) + else: + # Include orders that are active during the billing period + orders = Order.objects.filter( + customer=customer, + starting_date__lte=period_end, + ).filter( + models.Q(ending_date__isnull=True) | models.Q(ending_date__gte=period_start) + ) + + # Create line items for each order + for order in orders: + for product_order in order.product.all(): + + # Handle recurring charges + if product_order.timeframe: + recurring_price = calculate_recurring_price(product_order, period_start, period_end) + if recurring_price > 0: + BillLineItem.objects.create( + bill=bill, + product_order=product_order, + description=f"{product_order.product.name} - {product_order.timeframe.name}", + quantity=1, + unit_price=recurring_price + ) + + # Handle one-time charges (only if order started during this billing period) + if order.starting_date >= period_start and order.starting_date <= period_end: + onetime_price = calculate_onetime_price(product_order) + if onetime_price > 0: + BillLineItem.objects.create( + bill=bill, + product_order=product_order, + description=f"{product_order.product.name} - Setup Fee", + quantity=1, + unit_price=onetime_price + ) + + return bill + + +def calculate_recurring_price(product_order, period_start, period_end): + """ + Calculate recurring price for a product order within a time period + """ + # This is a placeholder - implement actual pricing logic + return 0.0 + + +def calculate_onetime_price(product_order): + """ + Calculate one-time price for a product order + """ + # This is a placeholder - implement actual pricing logic + return 0.0 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.
+Thank you for the order. The details are below: +
+Timeframe: {{ timeframe }}
+Selected timeframe: {{ timeframe }}
{% endif %} diff --git a/uncloud_v3/app/views.py b/uncloud_v3/app/views.py index 0478578..2ee139a 100644 --- a/uncloud_v3/app/views.py +++ b/uncloud_v3/app/views.py @@ -10,19 +10,35 @@ from .models import * from .forms import * from .services import * + +class OrderConfirmationView(TemplateView): + template_name = 'app/order_confirmation.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + po_id = self.request.session.pop('product_order_id', None) + context['product_order'] = get_object_or_404(ProductOrder, id=po_id) + return context + + class ProductOneTimeOrderView(FormView): form_class = ProductOneTimeOrderForm template_name = 'app/productorder_form.html' def get_success_url(self): - return "/" + return reverse("order-confirmation") def get_form_kwargs(self): + """ + Keys for the form + """ kwargs = super().get_form_kwargs() # Set the product so the form can retrieve the resources product = get_object_or_404(Product, slug=self.kwargs['product']) kwargs['resources'] = product.resources.all() + + print(f"kwargs = {kwargs}") return kwargs def get_initial(self): @@ -30,10 +46,15 @@ class ProductOneTimeOrderView(FormView): Initial values for the form """ - initial = super().get_initial() + initial = super().get_initial() initial['product'] = self.kwargs['product'] + product = get_object_or_404(Product, slug=self.kwargs['product']) + for res in product.resources.all(): + if res.default_value: + initial[res.slug] = res.default_value + if 'timeframe' in self.kwargs: initial['timeframe'] = self.kwargs['timeframe'] return initial @@ -58,8 +79,8 @@ class ProductOneTimeOrderView(FormView): else: timeframe = None - order_product(product, timeframe, form.cleaned_data) - + po = order_product(product, timeframe, form.cleaned_data) + self.request.session['product_order_id'] = po.id return super().form_valid(form) class ProductOrderView(ProductOneTimeOrderView): @@ -86,6 +107,10 @@ class ProductSelectView(CreateView): fields = ['product' ] class IndexView(TemplateView): + """ + The starting page containing a short intro + """ + template_name = "app/index.html" class Yearly(TemplateView): 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 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/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 diff --git a/uncloud_v3/requirements.txt b/uncloud_v3/requirements.txt index 673b4a9..b540b4a 100644 --- a/uncloud_v3/requirements.txt +++ b/uncloud_v3/requirements.txt @@ -1,4 +1,4 @@ # Django basics -Django==4.0 +Django==5.2.2 djangorestframework django-auth-ldap 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/