++bridge update
This commit is contained in:
		
					parent
					
						
							
								6b9b15e663
							
						
					
				
			
			
				commit
				
					
						a920887100
					
				
			
		
					 16 changed files with 275 additions and 138 deletions
				
			
		
							
								
								
									
										0
									
								
								bin/make-migrations-from-scratch.sh
									
										
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										0
									
								
								bin/make-migrations-from-scratch.sh
									
										
									
									
									
										
										
										Normal file → Executable 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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										19
									
								
								uncloud/migrations/0004_auto_20210101_1308.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								uncloud/migrations/0004_auto_20210101_1308.py
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										4
									
								
								uncloud/static/uncloud/uncloud.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								uncloud/static/uncloud/uncloud.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,4 @@
 | 
				
			||||||
 | 
					#content {
 | 
				
			||||||
 | 
					    width: 400px;
 | 
				
			||||||
 | 
						margin: auto;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,8 +3,16 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block body %}
 | 
					{% block body %}
 | 
				
			||||||
<div id="content">
 | 
					<div id="content">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div id="intro" class="row">
 | 
				
			||||||
 | 
					    <div class=col>
 | 
				
			||||||
      <h1>Welcome to uncloud</h1>
 | 
					      <h1>Welcome to uncloud</h1>
 | 
				
			||||||
  <div id="intro">
 | 
					    </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
 | 
					        Welcome to uncloud, the Open Source cloud management
 | 
				
			||||||
        system by <a href="https://ungleich.ch">ungleich</a>.
 | 
					        system by <a href="https://ungleich.ch">ungleich</a>.
 | 
				
			||||||
        It is an <a href="{% url 'api-root' %}">API</a> driven system with
 | 
					        It is an <a href="{% url 'api-root' %}">API</a> driven system with
 | 
				
			||||||
| 
						 | 
					@ -13,16 +21,45 @@
 | 
				
			||||||
          Framework</a>. You can
 | 
					          Framework</a>. You can
 | 
				
			||||||
        freely <a href="https://code.ungleich.ch/uncloud/uncloud/">access
 | 
					        freely <a href="https://code.ungleich.ch/uncloud/uncloud/">access
 | 
				
			||||||
          the source code of uncloud</a>.
 | 
					          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:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <ul>
 | 
				
			||||||
 | 
					        <li>First you need
 | 
				
			||||||
 | 
					        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>.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div id="creditcards">
 | 
					        <li>Secondy you will need to
 | 
				
			||||||
    <h2>Credit cards</h2>
 | 
					          <a href="{% url 'billingaddress-list' %}">create a billing
 | 
				
			||||||
    <div>
 | 
					            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>
 | 
					    <ul>
 | 
				
			||||||
      <li><a href="{% url 'cc_register' %}">Register a credit card</a>
 | 
					      <li><a href="{% url 'cc_register' %}">Register a credit card</a>
 | 
				
			||||||
        (this is required to be done via Javascript so that we never see
 | 
					        (this is required to be done via Javascript so that we never see
 | 
				
			||||||
| 
						 | 
					@ -35,19 +72,40 @@
 | 
				
			||||||
        then set it on another credit card.
 | 
					        then set it on another credit card.
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
  <div id="pay">
 | 
					  <div id="pay" class="row">
 | 
				
			||||||
    <h2>Payments and Balance</h2>
 | 
					    <div class="col"><h3>Billing Address, Payments and Balance</h3></div>
 | 
				
			||||||
    To trigger a payment
 | 
					    <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>
 | 
					      <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-list' %}">Make a payment or list your payments</a>
 | 
				
			||||||
        <li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
 | 
					        <li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
 | 
				
			||||||
      </ul>
 | 
					      </ul>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  <div id="net">
 | 
					  </div>
 | 
				
			||||||
    <h2>Networking</h2>
 | 
					
 | 
				
			||||||
    With uncloud you can use a variety of network related services.
 | 
					  <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
 | 
				
			||||||
| 
						 | 
					@ -56,7 +114,6 @@
 | 
				
			||||||
      %}">list which network sizes are available</a>
 | 
					      %}">list which network sizes are available</a>
 | 
				
			||||||
    </ul>
 | 
					    </ul>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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'),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 %}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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(
 | 
				
			||||||
 | 
					            "uncloud_pay/bill.html.j2",
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                'bill': bill,
 | 
				
			||||||
                'bill_records': bill.billrecord_set.all()
 | 
					                '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()
 | 
				
			||||||
                      })
 | 
					                      })
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										19
									
								
								uncloud_pay/migrations/0011_auto_20210101_1308.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								uncloud_pay/migrations/0011_auto_20210101_1308.py
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
						 | 
					@ -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')
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
									
								
							
							
						
						
									
										32
									
								
								uncloud_pay/services.py
									
										
									
									
									
										Normal 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)
 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue