++bridge update

This commit is contained in:
Nico Schottelius 2021-01-17 15:53:30 +01:00
parent 6b9b15e663
commit a920887100
16 changed files with 275 additions and 138 deletions

0
bin/make-migrations-from-scratch.sh Normal file → Executable file
View File

View File

@ -1,4 +1,4 @@
* Bootstrap / Installation * Bootstrap / Installation / Deployment
** Pre-requisites by operating system ** Pre-requisites by operating system
*** General *** General
To run uncloud you need: To run uncloud you need:
@ -150,7 +150,6 @@ g #+END_SRC
Workers usually should have an "uncloud" user account, even though Workers usually should have an "uncloud" user account, even though
strictly speaking the username can be any. strictly speaking the username can be any.
*** WireGuardVPN Server *** WireGuardVPN Server
- Allow write access to /etc/wireguard for uncloud user - Allow write access to /etc/wireguard for uncloud user
- Allow sudo access to "ip" and "wg" - Allow sudo access to "ip" and "wg"
@ -161,7 +160,11 @@ g #+END_SRC
app ALL=(ALL) NOPASSWD:/sbin/ip app ALL=(ALL) NOPASSWD:/sbin/ip
app ALL=(ALL) NOPASSWD:/usr/bin/wg app ALL=(ALL) NOPASSWD:/usr/bin/wg
#+END_SRC #+END_SRC
** Typical source code based deployment
- Deploy using bin/deploy.sh on a remote server
- Remote server should have
- postgresql running, accessible via TLS from outside
- rabbitmq-configured [in progress]
* Testing / CLI Access * Testing / CLI Access
Access via the commandline (CLI) can be done using curl or Access via the commandline (CLI) can be done using curl or
@ -462,6 +465,21 @@ Q vpn-2a0ae5c1200.ungleich.ch
- query on that flag - query on that flag
- verify it every time - verify it every time
***** TODO Generating bill for admins/staff ***** TODO Generating bill for admins/staff
- -
**** Bill fixes needed
***** TODO Double bill in bill id
***** TODO Name the currency
***** TODO Maybe remove the chromium pdf rendering artefacts
- date on the top
- title on the top
- filename bottom left
- page number could even stay
***** TODO Try to shorten the timestamp (remove time zone?)
***** TODO Bill date might be required
***** TODO Total and VAT are empty
***** TODO Line below detail/ heading

File diff suppressed because one or more lines are too long

View File

@ -61,7 +61,7 @@ class UncloudAddress(models.Model):
street = models.CharField(max_length=256) street = models.CharField(max_length=256)
city = models.CharField(max_length=256) city = models.CharField(max_length=256)
postal_code = models.CharField(max_length=64) postal_code = models.CharField(max_length=64)
country = CountryField(blank=True) country = CountryField(blank=False, null=False)
class Meta: class Meta:
abstract = True abstract = True

View File

@ -0,0 +1,4 @@
#content {
width: 400px;
margin: auto;
}

View File

@ -2,7 +2,6 @@
{% load bootstrap4 %} {% load bootstrap4 %}
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@ -15,7 +14,33 @@
{% block header %}{% endblock %} {% block header %}{% endblock %}
</head> </head>
<body> <body>
{% block bootstrap4_content %} {% block bootstrap4_content %}
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="{% url 'uncloudindex' %}">uncloud</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<!-- <li class="nav-item"> -->
<!-- <a class="nav-link" href="/random/">Generate random prefix</a> -->
<!-- </li> -->
{% if user.is_authenticated %}
<span class="navbar-text">Logged in as {{ user }}.</span>
<li class="nav-item">
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Login</a>
</li>
{% endif %}
</ul>
</div>
</nav>
<div class="container"> <div class="container">
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>

View File

@ -3,51 +3,109 @@
{% block body %} {% block body %}
<div id="content"> <div id="content">
<h1>Welcome to uncloud</h1>
<div id="intro">
Welcome to uncloud, the Open Source cloud management
system by <a href="https://ungleich.ch">ungleich</a>.
It is an <a href="{% url 'api-root' %}">API</a> driven system with
some convience views provided by
the <a href="https://www.django-rest-framework.org/">Django Rest
Framework</a>. You can
freely <a href="https://code.ungleich.ch/uncloud/uncloud/">access
the source code of uncloud</a>.
<div id="intro" class="row">
<div class=col>
<h1>Welcome to uncloud</h1>
</div>
</div>
<div id="intro" class="row">
<div class="col"><h3>About uncloud</h3></div>
<div class="col-8">
<p>
Welcome to uncloud, the Open Source cloud management
system by <a href="https://ungleich.ch">ungleich</a>.
It is an <a href="{% url 'api-root' %}">API</a> driven system with
some convience views provided by
the <a href="https://www.django-rest-framework.org/">Django Rest
Framework</a>. You can
freely <a href="https://code.ungleich.ch/uncloud/uncloud/">access
the source code of uncloud</a>.
</p>
</div>
</div>
<div id="howto" class="row">
<div class="col"><h3>Getting started</h3></div>
<div class="col-8">
<p>uncloud is designed to be as easy as possible to use. However,
there are some "real world" requirements that need to be met to
start using uncloud:
<div id="creditcards"> <ul>
<h2>Credit cards</h2> <li>First you need
<div> to <a href="https://account.ungleich.ch">register an
account</a>. If you already have one, you can
<a href="{% url 'login' %}">login</a>.
<li>If you have forgotten your password or other issues with
logging in, you can contact the ungleich support
via <strong>support at ungleich.ch</strong>.
<li>Secondy you will need to
<a href="{% url 'billingaddress-list' %}">create a billing
address</a>. This is required for determining the correct
tax.
<li>Next you will need to
<a href="{% url 'cc_register' %}">register a credit card</a>
from which payments can be made. Your credit card will not
be charged without your consent.
</ul>
</div>
</div>
<div id="creditcards" class="row">
<div class="col"><h3>Credit cards</h3></div>
<div class="col-8">
<p>
Credit cards are registered with stripe. We only save a the Credit cards are registered with stripe. We only save a the
last 4 digits and the expiry date of the card to make last 4 digits and the expiry date of the card to make
identification for you easier. identification for you easier.
</div> </p>
<div>
<ul>
<li><a href="{% url 'cc_register' %}">Register a credit card</a>
(this is required to be done via Javascript so that we never see
your credit card, but it is sent directly to stripe)
<li><a href="{% url 'stripecreditcard-list' %}">You can list your
credit cards</a>
By default the first credit card is used for charging
("active: true") and later added cards will not be
used. To change this, first disable the active flag and
then set it on another credit card.
</div>
</div>
<div id="pay">
<h2>Payments and Balance</h2>
To trigger a payment
<ul> <ul>
<li><a href="{% url 'cc_register' %}">Register a credit card</a>
<li><a href="{% url 'payment-list' %}">Make a payment or list your payments</a> (this is required to be done via Javascript so that we never see
<li><a href="{% url 'payment-balance-list' %}">Show your balance</a> your credit card, but it is sent directly to stripe)
</ul> <li><a href="{% url 'stripecreditcard-list' %}">You can list your
credit cards</a>
By default the first credit card is used for charging
("active: true") and later added cards will not be
used. To change this, first disable the active flag and
then set it on another credit card.
</div>
</div> </div>
<div id="net"> <div id="pay" class="row">
<h2>Networking</h2> <div class="col"><h3>Billing Address, Payments and Balance</h3></div>
With uncloud you can use a variety of network related services. <div class="col-8">
<p>Billing addresses behave similar to credit cards: you can
have many of them, but only one can be active. The active
billing address is taken for creating new orders.</p>
<p>In uncloud we use the pre-paid model: you can add money to
your account via payments. You can always check your
balance. The products you use will automatically be charged from
your existing balance.
</p>
<p>In the future you will be able opt-in to automatically
recharging your account at a certain time frame or whenever it
is below a certain amount</p>
<ul>
<li><a href="{% url 'billingaddress-list' %}">Create or list
your billing addresses</a>
<li><a href="{% url 'payment-list' %}">Make a payment or list your payments</a>
<li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
</ul>
</div>
</div>
<div id="net" class="row">
<div class="col"><h3>Networking</h3></div>
<div class="col-8">
<p>
With uncloud you can use a variety of network related
services.
</p>
<ul> <ul>
<li>You can <a href="{% url 'wireguardvpnnetwork-list' %}">list or <li>You can <a href="{% url 'wireguardvpnnetwork-list' %}">list or
@ -55,8 +113,7 @@
<li>You can also <a href="{% url 'wireguardvpnnetworksizes-list' <li>You can also <a href="{% url 'wireguardvpnnetworksizes-list'
%}">list which network sizes are available</a> %}">list which network sizes are available</a>
</ul> </ul>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -37,7 +37,7 @@ router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduc
# Pay # Pay
# router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress')
# router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill') # router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill')
# router.register(r'v1/my/order', payviews.OrderViewSet, basename='order') # router.register(r'v1/my/order', payviews.OrderViewSet, basename='order')
# router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method') # router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method')
@ -49,9 +49,9 @@ router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduc
# router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) # router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet)
# User/Account # User/Account
router.register(r'v1/my/user', authviews.UserViewSet, basename='user') # router.register(r'v1/my/user', authviews.UserViewSet, basename='user')
router.register(r'v1/admin/user', authviews.AdminUserViewSet, basename='useradmin') # router.register(r'v1/admin/user', authviews.AdminUserViewSet, basename='useradmin')
router.register(r'v1/user/register', authviews.AccountManagementViewSet, basename='user/register') # router.register(r'v1/user/register', authviews.AccountManagementViewSet, basename='user/register')
################################################################################ ################################################################################
@ -65,7 +65,7 @@ router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basenam
router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='stripecreditcard') router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='stripecreditcard')
router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment') router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment')
router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance') router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance')
router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress')
urlpatterns = [ urlpatterns = [
path(r'api/', include(router.urls), name='api'), path(r'api/', include(router.urls), name='api'),

View File

@ -1,13 +1,14 @@
{% extends 'uncloud/base.html' %} {% extends 'uncloud/base.html' %}
{% load bootstrap4 %}
{% block body %} {% block body %}
<div class="container"> <h1>Login to uncloud</h1>
<form method="post" class="form">
<form method="post">
{% csrf_token %} {% csrf_token %}
{{ form }} {% bootstrap_form form %}
<input type="submit" value="Login"> {% buttons %}
<button type="submit" class="btn btn-primary">Submit</button>
{% endbuttons %}
</form> </form>
</div>
{% endblock %} {% endblock %}

View File

@ -47,9 +47,13 @@ class BillAdmin(admin.ModelAdmin):
raise self._get_404_exception(object_id) raise self._get_404_exception(object_id)
output_file = NamedTemporaryFile() output_file = NamedTemporaryFile()
bill_html = render_to_string("bill.html.j2", {'bill': bill, bill_html = render_to_string(
'bill_records': bill.billrecord_set.all() "uncloud_pay/bill.html.j2",
}) {
'bill': bill,
'bill_records': bill.billrecord_set.all()
}
)
bytestring_to_pdf(bill_html.encode('utf-8'), output_file) bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
response = FileResponse(output_file, content_type="application/pdf") response = FileResponse(output_file, content_type="application/pdf")
@ -63,7 +67,7 @@ class BillAdmin(admin.ModelAdmin):
if bill is None: if bill is None:
raise self._get_404_exception(object_id) raise self._get_404_exception(object_id)
return render(request, 'bill.html.j2', return render(request, 'uncloud_pay/bill.html.j2',
{'bill': bill, {'bill': bill,
'bill_records': bill.billrecord_set.all() 'bill_records': bill.billrecord_set.all()
}) })

File diff suppressed because one or more lines are too long

View File

@ -1,24 +1,23 @@
import logging import logging
import itertools
import datetime import datetime
from math import ceil from math import ceil
from calendar import monthrange from calendar import monthrange
from decimal import Decimal from decimal import Decimal
from functools import reduce
from django.db import models
from django.db.models import Q
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from django.core.validators import MinValueValidator
from django.utils import timezone
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
# Verify whether or not to use them here
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
from uncloud.models import UncloudAddress from uncloud.models import UncloudAddress
from .services import *
# Used to generate bill due dates. # Used to generate bill due dates.
BILL_PAYMENT_DELAY=datetime.timedelta(days=10) BILL_PAYMENT_DELAY=datetime.timedelta(days=10)
@ -26,36 +25,6 @@ BILL_PAYMENT_DELAY=datetime.timedelta(days=10)
# Initialize logger. # Initialize logger.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def start_of_month(a_day):
""" Returns first of the month of a given datetime object"""
return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
def end_of_month(a_day):
""" Returns first of the month of a given datetime object"""
_, last_day = monthrange(a_day.year, a_day.month)
return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
def start_of_this_month():
""" Returns first of this month"""
a_day = timezone.now()
return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
def end_of_this_month():
""" Returns first of this month"""
a_day = timezone.now()
_, last_day = monthrange(a_day.year, a_day.month)
return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
def end_before(a_date):
""" Return suitable datetimefield for ending just before a_date """
return a_date - datetime.timedelta(seconds=1)
def start_after(a_date):
""" Return suitable datetimefield for starting just after a_date """
return a_date + datetime.timedelta(seconds=1)
def default_payment_delay(): def default_payment_delay():
return timezone.now() + BILL_PAYMENT_DELAY return timezone.now() + BILL_PAYMENT_DELAY
@ -68,7 +37,6 @@ class Currency(models.TextChoices):
# USD = 'USD', _('US Dollar') # USD = 'USD', _('US Dollar')
### ###
# Stripe # Stripe
@ -95,7 +63,7 @@ class StripeCreditCard(models.Model):
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=['owner'], models.UniqueConstraint(fields=['owner'],
condition=Q(active=True), condition=models.Q(active=True),
name='one_active_card_per_user') name='one_active_card_per_user')
] ]
@ -117,9 +85,7 @@ class Payment(models.Model):
('stripe', 'Stripe'), ('stripe', 'Stripe'),
('voucher', 'Voucher'), ('voucher', 'Voucher'),
('referral', 'Referral'), ('referral', 'Referral'),
('unknown', 'Unknown') ))
),
default='unknown')
timestamp = models.DateTimeField(default=timezone.now) timestamp = models.DateTimeField(default=timezone.now)
@ -135,6 +101,11 @@ class Payment(models.Model):
class PaymentMethod(models.Model): class PaymentMethod(models.Model):
"""
Not sure if this is still in use
"""
owner = models.ForeignKey(get_user_model(), owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE, on_delete=models.CASCADE,
editable=False) editable=False)
@ -151,15 +122,6 @@ class PaymentMethod(models.Model):
stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True)
stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True) stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True)
# @property
# def stripe_card_last4(self):
# if self.source == 'stripe' and self.active:
# payment_method = uncloud_pay.stripe.get_payment_method(
# self.stripe_payment_method_id)
# return payment_method.card.last4
# else:
# return None
@property @property
def active(self): def active(self):
if self.source == 'stripe' and self.stripe_payment_method_id != None: if self.source == 'stripe' and self.stripe_payment_method_id != None:
@ -276,7 +238,7 @@ class BillingAddress(UncloudAddress):
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=['owner'], models.UniqueConstraint(fields=['owner'],
condition=Q(active=True), condition=models.Q(active=True),
name='one_active_billing_address_per_user') name='one_active_billing_address_per_user')
] ]
@ -297,18 +259,13 @@ class BillingAddress(UncloudAddress):
if not billing_address: if not billing_address:
billing_address = cls.objects.create(owner=owner, billing_address = cls.objects.create(owner=owner,
organization="uncloud admins", organization="uncloud admins",
name="Uncloud Admin", full_name="Uncloud Admin",
street="Uncloudstreet. 42", street="Uncloudstreet. 42",
city="Luchsingen", city="Luchsingen",
postal_code="8775", postal_code="8775",
country="CH", country="CH",
active=True) active=True)
@staticmethod
def get_address_for(user):
return BillingAddress.objects.get(owner=user, active=True)
def __str__(self): def __str__(self):
return "{} - {}, {}, {} {}, {}".format( return "{} - {}, {}, {} {}, {}".format(
self.owner, self.owner,
@ -1186,7 +1143,7 @@ class Bill(models.Model):
return bill return bill
def __str__(self): def __str__(self):
return f"Bill {self.owner}-{self.id}" return f"{self.owner}-{self.id}"
class BillRecord(models.Model): class BillRecord(models.Model):
@ -1256,7 +1213,7 @@ class ProductToRecurringPeriod(models.Model):
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=['product'], models.UniqueConstraint(fields=['product'],
condition=Q(is_default=True), condition=models.Q(is_default=True),
name='one_default_recurring_period_per_product'), name='one_default_recurring_period_per_product'),
models.UniqueConstraint(fields=['product', 'recurring_period'], models.UniqueConstraint(fields=['product', 'recurring_period'],
name='recurring_period_once_per_product') name='recurring_period_once_per_product')

View File

@ -21,3 +21,6 @@ def get_spendings_for_user(user):
@transaction.atomic @transaction.atomic
def get_balance_for_user(user): def get_balance_for_user(user):
return get_payments_for_user(user) - get_spendings_for_user(user) return get_payments_for_user(user) - get_spendings_for_user(user)
def get_billing_address_for_user(user):
return BillingAddress.objects.get(owner=user, active=True)

View File

@ -36,6 +36,11 @@ class PaymentSerializer(serializers.ModelSerializer):
class BalanceSerializer(serializers.Serializer): class BalanceSerializer(serializers.Serializer):
balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS) balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS)
class BillingAddressSerializer(serializers.ModelSerializer):
class Meta:
model = BillingAddress
exclude = [ "owner" ]
################################################################################ ################################################################################
# Unchecked code # Unchecked code
@ -96,11 +101,6 @@ class BillRecordSerializer(serializers.Serializer):
amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
total = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) total = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
class BillingAddressSerializer(serializers.ModelSerializer):
class Meta:
model = BillingAddress
fields = ['uuid', 'organization', 'name', 'street', 'city', 'postal_code', 'country', 'vat_number']
class BillSerializer(serializers.ModelSerializer): class BillSerializer(serializers.ModelSerializer):
billing_address = BillingAddressSerializer(read_only=True) billing_address = BillingAddressSerializer(read_only=True)
records = BillRecordSerializer(many=True, read_only=True) records = BillRecordSerializer(many=True, read_only=True)

32
uncloud_pay/services.py Normal file
View File

@ -0,0 +1,32 @@
from django.utils import timezone
def start_of_month(a_day):
""" Returns first of the month of a given datetime object"""
return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
def end_of_month(a_day):
""" Returns first of the month of a given datetime object"""
_, last_day = monthrange(a_day.year, a_day.month)
return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
def start_of_this_month():
""" Returns first of this month"""
a_day = timezone.now()
return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
def end_of_this_month():
""" Returns first of this month"""
a_day = timezone.now()
_, last_day = monthrange(a_day.year, a_day.month)
return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
def end_before(a_date):
""" Return suitable datetimefield for ending just before a_date """
return a_date - datetime.timedelta(seconds=1)
def start_after(a_date):
""" Return suitable datetimefield for starting just after a_date """
return a_date + datetime.timedelta(seconds=1)

View File

@ -680,11 +680,9 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
</div> </div>
<div class="d4"> <div class="d4">
<div class="b1"> <div class="b1">
{{ bill.starting_date|date:"c" }} - Bill id: {{ bill }}
{{ bill.ending_date|date:"c" }} <br>{{ bill.starting_date|date:"Ymd" }} -
<br>Bill id: {{ bill }} {{ bill.ending_date|date:"Ymd" }}
<br>Due: {{ bill.due_date }}
</div> </div>
</div> </div>
<div style="clear: both;"></div> <div style="clear: both;"></div>
@ -703,8 +701,8 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
<tbody> <tbody>
{% for record in bill_records %} {% for record in bill_records %}
<tr class="table-list"> <tr class="table-list">
<td>{{ record.starting_date|date:"c" }} <td>{{ record.starting_date|date:"Ymd-H:i:s" }}
- {{ record.ending_date|date:"c" }} - {{ record.ending_date|date:"Ymd-H:i:s" }}
{{ record.order }} {{ record.order }}
</td> </td>
<td>{{ record.price|floatformat:2 }}</td> <td>{{ record.price|floatformat:2 }}</td>