Compare commits

...

20 Commits

Author SHA1 Message Date
Nico Schottelius ef4ca9d879 [uncloud_v3] update to django 5.0.2 2024-02-25 16:30:05 +09:00
Nico Schottelius 6fa10ef6d5 very old rename? 2023-11-25 22:34:47 +01:00
Nico Schottelius 95dfe62858 upgrade to python 3.11.1 2023-02-05 21:13:30 +01:00
Nico Schottelius 05eea37349 Cleanup and more demo products 2022-07-16 18:22:52 +02:00
nico14571 51851910c6 Merge pull request '35/order-confirmation-page' (#41) from 35/order-confirmation-page into master
Reviewed-on: #41
2022-03-04 20:04:31 +00:00
nico14571 86cb43a3c1 Merge pull request 'Do not allow to choose resource unit when min == max' (#40) from 38/restrict-selection into master
Reviewed-on: #40
2022-03-04 20:03:29 +00:00
nico14571 93013c8997 Merge pull request 'Update system dependency on libldap24 for archlinux' (#39) from mravi/uncloud-mravi:doc into master
Reviewed-on: #39
2022-03-04 20:00:11 +00:00
PCoder 1648355fe7 Add order_confirmation template 2022-02-22 13:33:45 +05:30
PCoder c50d688171 Add order-confirmation view and take user to this after the purchase 2022-02-22 13:33:29 +05:30
PCoder 72f47dec7c Return product_order object after creation 2022-02-22 13:32:31 +05:30
PCoder 8668e173b9 Do not allow to choose resource unit when min == max 2022-02-22 12:02:43 +05:30
PCoder a002711885 Update system dependency on libldap24 for archlinux 2022-02-22 11:00:53 +05:30
Nico Schottelius ee3b20227e ++helm ideas 2022-02-15 09:39:25 +01:00
Nico Schottelius a8001e36b0 Add index, allowed hosts 2022-01-30 14:47:01 +01:00
Nico Schottelius ba9bf94d8e Add support for onetimeprice and productordering 2022-01-30 13:00:58 +01:00
Nico Schottelius f08269640d cleanup commit, introduction of onetimeprice 2022-01-30 10:11:22 +01:00
Nico Schottelius 686d2c2b1e Switch to FormView for displaying the order 2022-01-16 17:13:59 +01:00
Nico Schottelius e910556952 Implement first part of the form 2022-01-16 00:31:59 +01:00
Nico Schottelius 1e69722f4b in between commit: html bug
- "range" is great in theory, but does not show actual nummber
- "number" does not allow float

Need to go back to text
2022-01-15 23:57:55 +01:00
Nico Schottelius 8952ef0b3a Improving on 3.0.0 / adding productorder link based on slug 2022-01-15 00:26:35 +01:00
37 changed files with 1020 additions and 113 deletions

View File

@ -17,6 +17,7 @@ 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`.

23
k8s/helm/.helmignore Normal file
View File

@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

24
k8s/helm/Chart.yaml Normal file
View File

@ -0,0 +1,24 @@
apiVersion: v2
name: django
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

View File

@ -0,0 +1,22 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "django.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "django.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "django.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "django.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "django.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "django.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "django.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "django.labels" -}}
helm.sh/chart: {{ include "django.chart" . }}
{{ include "django.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "django.selectorLabels" -}}
app.kubernetes.io/name: {{ include "django.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "django.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "django.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "django.fullname" . }}
labels:
{{- include "django.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "django.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "django.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "django.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@ -0,0 +1,28 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "django.fullname" . }}
labels:
{{- include "django.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "django.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,61 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "django.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "django.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "django.fullname" . }}
labels:
{{- include "django.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "django.selectorLabels" . | nindent 4 }}

View File

@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "django.serviceAccountName" . }}
labels:
{{- include "django.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "django.fullname" . }}-test-connection"
labels:
{{- include "django.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "django.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

82
k8s/helm/values.yaml Normal file
View File

@ -0,0 +1,82 @@
# Default values for django.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: nginx
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}

View File

@ -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

View File

@ -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

View File

@ -12,9 +12,65 @@ 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
#### 3.0.0
#### Future (unplanned)
* When/where to add timeframe constraints
* Timeframe slug-or-id
* Maybe slug and backlink to avail products
* Timeframe in product
* Should a product define list of time frames AND resources?
* Then can do autoselect on <resources x timeframes> and only show
complete ones
* resources are also timeframe bound
* name != unique (?)
* how do we link?
* stays towards resource
* we only show resources which have price_per_time with one of
our timeframes
* And we filter out timeframes that don't have all resources
* Can we filter drop down in admin?
* 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)
* Add basic validation to ordering
#### 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 ResourceOrders
* Need to pass in the price for the selected timeframe [done]
* On submit
* Create ProductOrder
* Create ResourceOrder(s)
#### 3.0.0 (2022-01-14)
* Introduce ProductOrderView
## Pre-Production requirements
* Products need to ensure *all* resources are consistent for different timeframes
* Products cannot have same resource linked twice in same timeframe

View File

@ -6,6 +6,7 @@ from .models import *
for m in [
Currency,
Order,
OneTimePrice,
PricePerTime,
Product,
ProductOrder,

44
uncloud_v3/app/forms.py Normal file
View File

@ -0,0 +1,44 @@
from django import forms
from django.forms import NumberInput
class ProductOneTimeOrderForm(forms.Form):
"""
For products that only contain onetimeresoures
"""
product = forms.SlugField(required=True, disabled=True)
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,
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()
print("Cleaning form myself ...")
class ProductOrderForm(ProductOneTimeOrderForm):
"""
For recurring products (might also have OneTime items)
"""
timeframe = forms.SlugField(required=False, disabled=True)

View File

@ -0,0 +1,110 @@
from django.core.management.base import BaseCommand
from app.models import *
class Command(BaseCommand):
help = 'Add test data'
# def add_arguments(self, parser):
#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"),
(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]
})
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])
PricePerTime.objects.get_or_create(timeframe=tf,
value=ppt[1],
defaults=
{
"currency": currency
})
# 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)
# 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") ]:
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)

View File

@ -1,4 +1,4 @@
# Generated by Django 4.0 on 2022-01-02 19:50
# Generated by Django 4.0 on 2022-01-16 16:44
from django.db import migrations, models
import django.db.models.deletion
@ -18,6 +18,7 @@ class Migration(migrations.Migration):
name='Currency',
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, unique=True)),
('short_name', models.CharField(max_length=3, unique=True)),
],
@ -26,7 +27,7 @@ class Migration(migrations.Migration):
name='PricePerTime',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.FloatField()),
('value', models.FloatField()),
('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')),
],
),
@ -34,6 +35,7 @@ class Migration(migrations.Migration):
name='Product',
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, unique=True)),
],
),
@ -41,8 +43,9 @@ class Migration(migrations.Migration):
name='Resource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, unique=True)),
('unit', models.CharField(max_length=128, unique=True)),
('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)),
@ -53,6 +56,7 @@ class Migration(migrations.Migration):
name='TimeFrame',
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, unique=True)),
('seconds', models.IntegerField(blank=True, null=True)),
],
@ -63,7 +67,6 @@ class Migration(migrations.Migration):
('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')),
('timeframe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe')),
],
),
migrations.CreateModel(
@ -72,12 +75,18 @@ class Migration(migrations.Migration):
('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(to='app.Resource'),
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',
@ -95,4 +104,12 @@ class Migration(migrations.Migration):
('product', models.ManyToManyField(blank=True, to='app.ProductOrder')),
],
),
migrations.CreateModel(
name='OneTimePrice',
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')),
],
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 4.0 on 2022-01-02 19:53
from django.db import migrations
def gen_timeframes(apps, schema_editor):
# We can't import the Person model directly as it may be a newer
# version than this migration expects. We use the historical version.
TimeFrame = apps.get_model('app', 'TimeFrame')
for timeframe in [ (3600, "1 hour"),
(86400, "1 day"),
(7*86400, "7 days"),
(30*86400, "30 days"),
(365*86400, "365 days") ]:
tf = TimeFrame(name=timeframe[1],
seconds=timeframe[0])
tf.save()
class Migration(migrations.Migration):
dependencies = [
('app', '0001_initial'),
]
operations = [
migrations.RunPython(gen_timeframes),
]

View File

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

View File

@ -1,23 +0,0 @@
# Generated by Django 4.0 on 2022-01-02 20:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0002_auto_20220102_1953'),
]
operations = [
migrations.AlterField(
model_name='product',
name='resources',
field=models.ManyToManyField(blank=True, to='app.Resource'),
),
migrations.AlterField(
model_name='resource',
name='unit',
field=models.CharField(max_length=128),
),
]

View File

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

View File

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

View File

@ -1,19 +0,0 @@
# Generated by Django 4.0 on 2022-01-02 20:20
from django.db import migrations
def gen_currencies(apps, schema_editor):
Currency = apps.get_model('app', 'Currency')
Currency.objects.get_or_create(name="Swiss Franc",
short_name="CHF")
class Migration(migrations.Migration):
dependencies = [
('app', '0003_alter_product_resources_alter_resource_unit'),
]
operations = [
migrations.RunPython(gen_currencies),
]

View File

@ -1,16 +1,19 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.urls import reverse
from django.db.models import Q
class Currency(models.Model):
slug = models.SlugField(null=True, unique=True)
name = models.CharField(max_length=128, unique=True)
short_name = models.CharField(max_length=3, unique=True)
def __str__(self):
return f"{self.name} ({self.short_name})"
class TimeFrame(models.Model):
slug = models.SlugField(null=True, unique=True)
name = models.CharField(max_length=128, unique=True)
seconds = models.IntegerField(null=True, blank=True)
@ -34,22 +37,36 @@ class TimeFrame(models.Model):
#return "{} ({})".format(self.name, self.secs_to_name(self.seconds))
return f"{self.name}"
class OneTimePrice(models.Model):
value = models.FloatField()
currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
class Meta:
ordering = ('value',)
def __str__(self):
return f"{self.value} {self.currency.short_name}"
class PricePerTime(models.Model):
timeframe = models.ForeignKey(TimeFrame, on_delete=models.CASCADE)
price = models.FloatField()
value = models.FloatField()
currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
def __str__(self):
return f"{self.price} {self.currency.short_name}/{self.timeframe}"
return f"{self.value}{self.currency.short_name}/{self.timeframe}"
class Resource(models.Model):
name = models.CharField(max_length=128, unique=True) # CPU, RAM
slug = models.SlugField(null=True, unique=True) # 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
step_size = models.FloatField(default=1) # might/must step size
step_size = models.FloatField(default=1) # step size
price_per_time = models.ManyToManyField(PricePerTime, blank=True)
onetime_price = models.ForeignKey(OneTimePrice,
null=True, blank=True,
on_delete=models.CASCADE)
def __str__(self):
if self.minimum_units:
@ -61,21 +78,10 @@ class Resource(models.Model):
else:
maximum = "No maximum"
pricing = []
for price in self.price_per_time.all():
pricing.append(f"{price.price}{price.currency.short_name}/{price.timeframe}")
pricing = ", ".join([str(x) for x in self.price_per_time.all()])
pricing = ", ".join(pricing)
return f"{self.name}: {minimum}-{maximum} (+/-){self.step_size} {self.unit} ({pricing})"
class ResourceOrder(models.Model):
"""
Resources that have been ordered
"""
timeframe = models.ForeignKey(TimeFrame, on_delete=models.CASCADE)
value = models.FloatField()
resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
#return f"{self.slug}: {minimum}-{maximum} (+/-){self.step_size} {self.unit} ({pricing})"
return f"{self.name} ({self.slug})"
class Product(models.Model):
@ -83,23 +89,78 @@ class Product(models.Model):
Describes a product a user can buy
"""
slug = models.SlugField(null=True, unique=True)
name = models.CharField(max_length=128, unique=True)
# textconfig = models.ManyToManyField(ProductTextConfiguration)
# textfieldconfig = models.ManyToManyField(ProductTextFieldConfiguration)
resources = models.ManyToManyField(Resource, blank=True) # List of REQUIRED resources
timeframes = models.ManyToManyField(TimeFrame, blank=True) # List of POSSIBLE timeframes
resources = models.ManyToManyField(Resource, blank=True)
def has_one_time_price(self):
has_otp = False
for res in self.resources.all():
if res.onetime_price:
has_otp = True
break
return has_otp
def valid_timeframes(self):
"""
Return all timeframes that have all resources configured
"""
valid_tf = []
num_res = self.resources.all().count()
for tf in self.timeframes.all():
# Get all distinct source for this timeframe
res = self.resources.filter(price_per_time__timeframe=tf).distinct().count()
if res == num_res:
valid_tf.append(tf)
return valid_tf
def get_absolute_url(self):
return reverse('product-detail', kwargs={'slug' : self.slug})
def __str__(self):
return self.name
class ResourceOrder(models.Model):
"""
Resources that have been ordered
We need to record the selected value *and* potentially the
calculated price
"""
value = models.FloatField()
resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
def __str__(self):
return f"{self.value} x {self.resource}"
class ProductOrder(models.Model):
"""
Describes a product a user bought
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE)
timeframe = models.ForeignKey(TimeFrame, null=True, blank=True, on_delete=models.CASCADE)
resources = models.ManyToManyField(ResourceOrder)
def __str__(self):
if self.timeframe:
txt = f"Order {self.id}: {self.product} for {self.timeframe}"
else:
txt = f"Order {self.id}: {self.product}"
return txt
class Order(models.Model):
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False)

View File

@ -0,0 +1,30 @@
from django.shortcuts import get_object_or_404
from .models import *
def order_product(product, timeframe, formdata):
"""
Order a product with given parameters
"""
print(formdata)
po = ProductOrder(product=product, timeframe=timeframe)
po.save()
for res, value in formdata.items():
print(f"{res}={value}")
# skip fixed fields
if res == 'product' or res == 'timeframe':
continue
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)

View File

@ -0,0 +1,12 @@
<h1>Welcome to uncloud</h1>
<h2>What is uncloud?</h2>
At ungleich we have developed uncloud as an order system that allows
you to manage all your resources.
<h2>What can I do with uncloud?</h2>
<ul>
<li>You can <a href="{% url 'products' %}">order products</a></li>
</ul>

View File

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

View File

@ -0,0 +1,20 @@
<h1>{{ object.name }}</h1>
(description to be added here)
<ul>
{% for tf in timeframes %}
<li><a href="{% url 'product-order-tf' object.slug tf.slug %}">Buy
{{ object.name }}
for {{ tf }}</a>
</li>
{% endfor %}
{% if not timeframes %}
{% if has_one_time_price %}
<li><a href="{% url 'product-order-onetime' object.slug %}">Buy
{{ object.name }}</a>
</li>
{% endif %}
{% endif %}
</ul>

View File

@ -0,0 +1,13 @@
<h1>Select Product</h1>
<h2>Product List</h2>
<ul>
{% for product in object_list %}
{% if product.slug %}
<li>
<a href="{{ product.get_absolute_url }}">{{ product.name }}</a>
</li>
{% endif %}
{% endfor %}
</ul>

View File

@ -1,4 +1,13 @@
<form method="post">{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Save">
<h2>Order {{ product }}</h2>
{% if timeframe %}
<p>Timeframe: {{ timeframe }}</p>
{% endif %}
<form method="post" >
{% csrf_token %}
<table>
{{ form }}
</table>
<button type="submit" class="btn btn-primary">Order</button>
</form>

View File

@ -1,8 +1,113 @@
from django.shortcuts import render
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.views.generic.edit import CreateView
from .models import ProductOrder
from django.views.generic.edit import CreateView, FormView
from django.views.generic.base import TemplateView
from django.views.generic.list import ListView
from django.views.generic.detail import DetailView
class ProductOrderView(CreateView):
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 reverse("order-confirmation")
def get_form_kwargs(self):
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()
return kwargs
def get_initial(self):
"""
Initial values for the form
"""
initial = super().get_initial()
initial['product'] = self.kwargs['product']
if 'timeframe' in self.kwargs:
initial['timeframe'] = self.kwargs['timeframe']
return initial
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['product'] = get_object_or_404(Product, slug=self.kwargs['product'])
if 'timeframe' in context:
context['timeframe'] = get_object_or_404(TimeFrame, slug=self.kwargs['timeframe'])
return context
def form_valid(self, form):
product = get_object_or_404(Product, slug=form.cleaned_data['product'])
print("We got a valid form, let's create the order, listing fields:\n------")
for f in form.fields:
print(f)
print(form.cleaned_data)
if 'timeframe' in form.cleaned_data:
timeframe = get_object_or_404(TimeFrame, slug=form.cleaned_data['timeframe'])
else:
timeframe = None
po = order_product(product, timeframe, form.cleaned_data)
self.request.session['product_order_id'] = po.id
return super().form_valid(form)
class ProductOrderView(ProductOneTimeOrderView):
form_class = ProductOrderForm
class ProductDetailView(DetailView):
model = Product
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['productorder'] = reverse('product-order', kwargs={'product': self.object.slug })
context['timeframes'] = context['product'].valid_timeframes()
context['has_one_time_price'] = context['product'].has_one_time_price()
print(context)
return context
class ProductListView(ListView):
model = Product
class ProductSelectView(CreateView):
model = ProductOrder
fields = ['product', 'resources' ]
fields = ['product' ]
class IndexView(TemplateView):
"""
The starting page containing a short intro
"""
template_name = "app/index.html"
class Yearly(TemplateView):
template_name = "app/config_product.html"
def get_context_data(self, **kwargs):
context = super(Yearly, self).get_context_data(**kwargs)
context['current_year'] = self.current_year
context['current_month'] = self.current_month
return context

View File

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

View File

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

View File

@ -26,7 +26,7 @@ SECRET_KEY = os.environ['SECRET_KEY'] if 'SECRET_KEY' in os.environ else 'a bad
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*']
# Application definition

View File

@ -19,5 +19,13 @@ from app import views as appviews
urlpatterns = [
path('admin/', admin.site.urls),
path('order', appviews.ProductOrderView.as_view())
path('order', appviews.ProductSelectView.as_view()),
path('order/<slug:product>/', appviews.ProductOrderView.as_view(), name='product-order'),
path('order/recurring/<slug:product>/<slug:timeframe>/', appviews.ProductOrderView.as_view(), name='product-order-tf'),
path('order/onetime/<slug:product>/', appviews.ProductOneTimeOrderView.as_view(), name='product-order-onetime'),
path('order-confirmation', appviews.OrderConfirmationView.as_view(), name='order-confirmation'),
path('product/', appviews.ProductListView.as_view(), name='products'),
path('product/<slug:slug>/', appviews.ProductDetailView.as_view(), name='product-detail'),
path('', appviews.IndexView.as_view(), name='index'),
]