Compare commits

..

No commits in common. "master" and "3.0.0" have entirely different histories.

37 changed files with 113 additions and 1020 deletions

View file

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

View file

@ -1,23 +0,0 @@
# 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/

View file

@ -1,24 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,62 +0,0 @@
{{/*
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

@ -1,61 +0,0 @@
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

@ -1,28 +0,0 @@
{{- 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

@ -1,61 +0,0 @@
{{- 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

@ -1,15 +0,0 @@
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

@ -1,12 +0,0 @@
{{- 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

@ -1,15 +0,0 @@
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

View file

@ -1,82 +0,0 @@
# 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.11.1-alpine3.17
FROM python:3.10.0-alpine3.15
WORKDIR /usr/src/app

View file

@ -1,12 +1,4 @@
IMAGE=ungleich/uncloud
all: requirements build
build:
sh -c 'docker build -t $(IMAGE):$$(git describe) .'
pub: build
docker push $(IMAGE):$$(git describe)
all: requirements
run: requirements
. ./env && python manage.py runserver

View file

@ -12,65 +12,9 @@ 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
#### 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)
#### 3.0.0
* 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,7 +6,6 @@ from .models import *
for m in [
Currency,
Order,
OneTimePrice,
PricePerTime,
Product,
ProductOrder,

View file

@ -1,44 +0,0 @@
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

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

@ -0,0 +1,28 @@
# 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

@ -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'),
),
]

View file

@ -0,0 +1,23 @@
# 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

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -0,0 +1,19 @@
# 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,19 +1,16 @@
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)
@ -37,36 +34,22 @@ 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)
value = models.FloatField()
price = models.FloatField()
currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
def __str__(self):
return f"{self.value}{self.currency.short_name}/{self.timeframe}"
return f"{self.price} {self.currency.short_name}/{self.timeframe}"
class Resource(models.Model):
slug = models.SlugField(null=True, unique=True) # primary identifier
name = models.CharField(max_length=128, unique=False) # CPU, RAM
name = models.CharField(max_length=128, unique=True) # 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) # step size
step_size = models.FloatField(default=1) # might/must 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:
@ -78,10 +61,21 @@ class Resource(models.Model):
else:
maximum = "No maximum"
pricing = ", ".join([str(x) for x in self.price_per_time.all()])
pricing = []
for price in self.price_per_time.all():
pricing.append(f"{price.price}{price.currency.short_name}/{price.timeframe}")
#return f"{self.slug}: {minimum}-{maximum} (+/-){self.step_size} {self.unit} ({pricing})"
return f"{self.name} ({self.slug})"
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)
class Product(models.Model):
@ -89,78 +83,23 @@ 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)
resources = models.ManyToManyField(Resource, blank=True) # List of REQUIRED resources
timeframes = models.ManyToManyField(TimeFrame, blank=True) # List of POSSIBLE timeframes
# textconfig = models.ManyToManyField(ProductTextConfiguration)
# textfieldconfig = models.ManyToManyField(ProductTextFieldConfiguration)
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})
resources = models.ManyToManyField(Resource, blank=True)
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

@ -1,30 +0,0 @@
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

@ -1,12 +0,0 @@
<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

@ -1,13 +0,0 @@
<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

@ -1,20 +0,0 @@
<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

@ -1,13 +0,0 @@
<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,13 +1,4 @@
<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 method="post">{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Save">
</form>

View file

@ -1,113 +1,8 @@
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.shortcuts import render
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
from django.views.generic.edit import CreateView
from .models import ProductOrder
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):
class ProductOrderView(CreateView):
model = ProductOrder
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
fields = ['product', 'resources' ]

View file

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

View file

@ -1,4 +1,4 @@
# Django basics
Django==5.0.2
Django==4.0
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,13 +19,5 @@ from app import views as appviews
urlpatterns = [
path('admin/', admin.site.urls),
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'),
path('order', appviews.ProductOrderView.as_view())
]