iT邦幫忙

2022 iThome 鐵人賽

DAY 27
0

Day 27 合法克隆,複製你的 Django 租戶

如果要新增一個 Django 租戶,最好的方式並不是重新建立一個全新的,而是先擁有一個設定好初始資料的基底租戶,在要新增租戶的時候直接從這個基底租戶進行複製,就不需要重複進行初始化了。
然而要連到主機裡面下 clone 指令是很麻煩的,為了簡化日後的作業流程,可以將複製租戶的功能新增至管理介面,馬上就來看看要如何進行吧!

租戶指令

在之前的『第一個房客,建立租戶』有提到建立租戶與其他基礎指令,現在要來了解透過指令要如何對租戶進行複製與刪除。

複製租戶

clone 為複製租戶指令,這裡將 example01 作為基底租戶進行複製, 以下為參數說明:

  • clone_from 為要複製的租戶 schema
  • schema_name 為新租戶的 schema 名稱
  • name 為新租戶的名稱
  • domain-tenant_id 為新租戶的 id
  • domain-domain 為新租戶的預設 domain

這裡要注意最後這些參數會執行 PostgreSQL 的指令,schema 名稱不可使用 . 或是純數字以免造成指令發生錯誤。

複製租戶範例指令:

docker exec --workdir /opt/app/web example_tenant_web \
	python3.10 manage.py clone_tenant \
	--clone_from example01 \
	--clone_tenant_fields True \
	--schema_name example03 \
	--name example03 \
	--domain-domain example03.localhost \
	--domain-tenant_id example03 \
	--domain-is_primary True

刪除租戶

delete_tenant 為刪除租戶指令, -s 為指定要刪除的 schema 名稱。

docker exec - it --workdir /opt/app/web example_tenant_web \
	python3.10 manage.py delete_tenant -s example03

...

Warning you are about to delete 'example03' there is no undo.
Are you sure you want to delete 'example03'?yes
Deleting 'example03'
Deleted 'example03'

建立新增租戶表單

首先要定義一個新增租戶的表單,最終會透過 clone 指令來進行複製,

以下為表單欄位說明:

  • name 為新租戶名稱
  • from_schema_name 為要複製的 schema 名稱
  • to_schema_name 為新增的 schema 名稱
  • description 為租戶說明
  • domain 為預設網域

這些欄位需要進行驗證是否重複與 schema 名稱不可使用 . 或是純數字。

在 customers 建立 forms.py,並建立 CustomersForm 表單:

from django import forms
from django.db import models
from customers.models import Client, Domain

class CustomersForm(forms.Form):
    
    def __init__(self, *args, **kwargs):
        super(CustomersForm, self).__init__(*args, **kwargs)
        self.fields['name'].label = "租戶名稱"
        self.fields['from_schema_name'].label = "要複製的 schema 名稱"
        self.fields['to_schema_name'].label = "新增的 schema 名稱"
        self.fields['description'].label = "說明"
        self.fields['domain'].label = "網域"
    
    name = forms.CharField(required=True)
    from_schema_name = forms.CharField(required=True)
    to_schema_name = forms.CharField(required=True)
    description = forms.CharField(required=True)
    domain = forms.CharField(required=True)

    def clean_name(self):
        data = self.cleaned_data['name']
        if Client.objects.filter(name=data).exists():
            raise forms.ValidationError(f'租戶名稱 "{data}" 已存在。')
        return data

    def clean_from_schema_name(self):
        data = self.cleaned_data['from_schema_name']
        if not Client.objects.filter(schema_name=data).exists():
            raise forms.ValidationError(f'要複製的 schema 名稱 "{data}" 不存在。')
        return data
        
    def clean_to_schema_name(self):
        data = self.cleaned_data['to_schema_name']
        if '.' in data:
            raise forms.ValidationError(f'新建的 schema 名稱 "{data}" 不可包含 "." 字元。')
        if not data.isalnum() or data.isdigit():
            raise forms.ValidationError(f'新建的 schema 名稱 "{data}" 需英數字混和。')
        if Client.objects.filter(schema_name=data).exists():
            raise forms.ValidationError(f'新建的 schema 名稱 "{data}" 已存在。')
        return data
        
    def clean_domain(self):
        data = self.cleaned_data['domain']
        if Domain.objects.filter(domain=data).exists():
            raise forms.ValidationError(f'網域"{data}" 已存在。')
        return data

建立新增租戶視圖

在 customers 應用程式的 views.py 建立 CustomersFormView 視圖。

由於我們接下來要使用自定義管理介面,需要使用 LoginRequiredMixin 來要求使用者進行登入,另外則是在 get 方法限制特定的超級使用者帳號才可進行新增,與取得 admin.site 的 context 傳遞給 template。

指定表單 template 為 customers/customers_form.html(將在之後建立)。

使用 form_valid 函數在表單驗證通過後執行 clone 指令,並且複製一份多語系檔。

# customers/views.py

import os
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import connection
from django.http import HttpResponseRedirect, HttpResponseNotFound
from django.views.generic.edit import FormView
from django.views.decorators.cache import never_cache
from django.utils.decorators import method_decorator
from django.urls import reverse

from django_tenants.management.commands.clone_tenant import Command

from customers.models import Client
from random import choice

from customers.forms import CustomersForm

@method_decorator(never_cache, name='dispatch')
class CustomersFormView(LoginRequiredMixin, FormView):
    login_url = '/admin/login/'
    template_name = 'customers/customers_form.html'
    form_class = CustomersForm

    def get_context_data(self, **kwargs):
        context = super(CustomersFormView, self).get_context_data(**kwargs)
        return context

    def form_valid(self, form):
        now_schema_name = connection.schema_name
        name = form.cleaned_data['name']
        from_schema_name = form.cleaned_data['from_schema_name']
        to_schema_name = form.cleaned_data['to_schema_name']
        domain = form.cleaned_data['domain']
        description = form.cleaned_data['description']

        c = Command()
        tenant_data = {'schema_name': to_schema_name, 'name': name, 'description': description}
        tenant = c.store_tenant(clone_schema_from=from_schema_name,
                                    clone_tenant_fields=True,
                                    **tenant_data)
        tenant.name = name
        tenant.save()
        domain_data = {'domain': domain, 'tenant': tenant, 'is_primary': 'True'}
        domain = c.store_tenant_domain(**domain_data)
        connection.set_schema(now_schema_name)
        # locale dir
        from_locale_path = locale_path = os.path.join(settings.BASE_DIR, f'tenant_locale/{from_schema_name}')
        to_locale_path = locale_path = os.path.join(settings.BASE_DIR, f'tenant_locale/{to_schema_name}')
        if not os.path.isdir(to_locale_path):
            print(f"cp -pr {from_locale_path} {to_locale_path}")
            os.system(f"cp -pr {from_locale_path} {to_locale_path}")
        return HttpResponseRedirect(reverse('admin:customers_client_changelist'))

    def get_success_url(self):
        return reverse('admin:customers_client_changelist')

    def post(self, request, *args, **kwargs):
        form = self.get_form()
        if form.is_valid():
            return self.form_valid(form)
        else:
            context = self.get_context_data(**kwargs)
            request.current_app = 'admin'
            context.update(admin.site.each_context(request))
            return self.render_to_response(context)

    def get(self, request, *args, **kwargs):
        # 限制特定使用者才可進入該頁面
        if not request.user.username.startswith('admin') and not request.user.is_superuser:
            return HttpResponseRedirect(reverse('admin:index'))
        context = self.get_context_data(**kwargs)
        request.current_app = 'admin'
        context.update(admin.site.each_context(request))
        return self.render_to_response(context)

建立表單模板

在 customers 應用程式建立 templates 目錄後其中再建立 customers 目錄,最後建立 customers_form.html。

這裡繼承 Django 的管理介面 admin/base_site.html,並使用 form 生成表單。

<!-- customers/templates/customers/customers_form.html -->

{% extends "admin/base_site.html" %}
{% load i18n static %}

{% block breadcrumbs %}
<div class="breadcrumbs">
  <a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
  {% if title %} › {% if back %} <a href="{{ back }}">{{ title }}</a> {% else %} {{ title }} {% endif %}{% endif %}
  {% if subtitle %} › {{ subtitle|safe }}{% endif %}
</div>
{% endblock %}

{% block content %}

<meta name="robots" content="NONE,NOARCHIVE" />
<style>

</style>

<div class="card">
  <div class="card-block">
    <div>
      <fieldset class="form module">
        <form id="form" name="form" method="post">
          {% csrf_token %}
          {{form.media}}
          <div class="col-xs-12 col-sm-9 col-md-10 col-multi-fields">
            <table border="0" width="100%">
              {% for field in form %}
                <tr>
                  <td colspan=2>
                    <div class="form-group row form-row">
                      <label class="form-control-label col-xs-12 col-sm-3 col-md-2 {% if field.field.required %} field-required{% endif %}"
                        id="{{ field.auto_id }}_filter" name="{{ field.name }}">{{field.label}}</label>
                      <div class="col-xs-9">{{field}}</div>
                      {% for error in field.errors %} 
                        {{ error }}
                      {% endfor %}
                    </div>
                  </td>
                </tr>
              {% endfor %}
              <tr>
                <td colspan=2> </td>
              </tr>
              <tr>
                <td colspan=2>
                  <center>
                    <button class="btn btn-primary" type="submit" value="新增" name="add">新增</button>
                  </center>
                </td>
              </tr>
            </table>
          </div>
        </form>
      </fieldset>
    </div>
  </div>
</div>

{% endblock %}

建立 URLS 路由

在 main 應用程式下的 URLS 進行匯入

# main/urls.py

# ...

urlpatterns = [
    # ...
    path('customers/', include(('customers.urls', 'customers'), namespace='customers')),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

# ...

在 customers 應用程式目錄下建立 urls.py 檔案,

form 對應至 CustomersFormView 新增租戶表單視圖。

from django.urls import path
from django.urls.conf import include

from . import views

urlpatterns = [
    path('form/', views.CustomersFormView.as_view(), name='customers_form'),
]

調整租戶列表管理介面

新增按鈕

接下來要增加一個新增租戶的按鈕,為了能自定義按鈕區塊,要繼承 Django 原生的 admin 模板 change_list.html 來進行修改。

將 Django 模板複製至 customers 應用程式模板的指令如下:

docker exec example_tenant_web \
	cp -pr \
	/usr/local/lib/python3.10/dist-packages/django/contrib/admin/templates/admin/change_list.html \
	/opt/app/web/customers/templates/customers/

在 change_list.html 的 object-tools-items 區塊 增加新增租戶按鈕:

<!-- ... -->

{% block object-tools-items %}
  <li>
    <a href="{% url 'customers:customers_form' %}" class="not add_tenant">新增租戶</a>
  </li>
  {% if has_add_permission %}
  <li>
    {% url cl.opts|admin_urlname:'add' as add_url %}
    <a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink">
      {% blocktranslate with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktranslate %}
    </ a>
  </li>
  {% endif %}
{% endblock %}

指定模板與權限控制

接著更新租戶管理介面,指定 change_list_template 為自定義的模板,並調整管介面權限,只有特定超級使用者帳號才可以進行刪除租戶。

# customers/admin.py

from django.contrib import admin

from customers.models import Client, Domain
from . import forms

class DomainInline(admin.TabularInline):
    model = Domain

@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
    change_list_template = "customers/change_list.html"
    inlines = [DomainInline]
    list_display = ('schema_name', 'name')

    def has_add_permission(self, request):
        return False

    def has_delete_permission(self, request, obj=None):
        # 限制特定使用者才可刪除
        if request.user.username.startswith('admin') and request.user.is_superuser:
            return True

刪除租戶功能

透過指令刪除 schema 需要將 auto_drop_schema 設為 True。

在模型執行 delete 函數時,同時將多語系檔案也進行刪除。

# customers/models.py

import os
from django.conf import settings
from django.db import models, connection
from django_tenants.models import TenantMixin, DomainMixin

class Client(TenantMixin):
    name = models.CharField(max_length=100)
    paid_until =  models.DateField()
    on_trial = models.BooleanField()
    created_on = models.DateField(auto_now_add=True)

    # default true, schema will be automatically created and synced when it is saved
    auto_create_schema = True
    auto_drop_schema = True

    def delete(self, force_drop=False, *args, **kwargs):
        """
        Deletes this row. Drops the tenant's schema if the attribute
        auto_drop_schema set to True.
        """
        locale_path = locale_path = os.path.join(settings.BASE_DIR, f'tenant_locale/{connection.schema_name}')
        os.system(f"rm -rf {locale_path}")
        self._drop_schema(force_drop)
        super().delete(*args, **kwargs)

class Domain(DomainMixin):
    pass

然而透過管理介面進行刪除操作在預設的情況會進行 admin log 的紀錄,資料庫會因為這條紀錄尚未執行完畢而無法正常刪除 schema,所以要在 admin 將刪除 log 紀錄功能停用。

# customers/admin.py

# ...

class ClientAdmin(admin.ModelAdmin):

    # ...

    def log_deletion(self, request, object, object_repr):
        pass

到這裡,新增與刪除租戶功能就都完成了。

功能展示:

租戶列表管理介面:

在右上角可以看到新增租戶按鈕,點選後進入新增租戶表單頁面。

https://ithelp.ithome.com.tw/upload/images/20221009/20151656q5GKCRjUwR.png

新增租戶表單頁面:

這裡我們要複製租戶 example01 為新租戶 example03 。

https://ithelp.ithome.com.tw/upload/images/20221009/20151656S1T3lueojO.png

點選新增送出表單。

https://ithelp.ithome.com.tw/upload/images/20221009/20151656ZLbzeZLbwd.png

登入租戶 example03 管理介面

由以下網址進入

http://example03.localhost:8000/admin/

因為是從租戶 example01 複製過來,登入時使用租戶 example01 的超級使用者帳號密碼

https://ithelp.ithome.com.tw/upload/images/20221009/20151656rvCstBSlca.png

租戶 example03 的管理介面

進入後可以看到租戶 example03 的資料與刪除按鈕,刪除按鈕只能刪除當前租戶,無法刪除其他租戶。

https://ithelp.ithome.com.tw/upload/images/20221009/20151656u9O9jT2mve.png

刪除租戶

點選刪除按鈕後點選確認。

https://ithelp.ithome.com.tw/upload/images/20221009/20151656fE6uyhbuvz.png

刪除完成,因為該租戶已經被刪除,會返回 404 頁面。

https://ithelp.ithome.com.tw/upload/images/20221009/20151656iG4IvQvruc.png

大功告成!

今天透過管理介面來操作租戶,可以省下許多開發工作,但也要注意對超級使用者的管控,避免誤操作導致資料被刪除哦。

接下來我們將來把電商網站的訂單功能完成,下一回『全都要買,Django 實作購物車功能』。


上一篇
Day 26 Django 來找查,實作搜尋功能
下一篇
Day 28 全都要買,Django 實作購物車功能
系列文
全能住宅改造王,Django 多租戶架構的應用 —— 實作一個電商網站30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言