diff --git a/orchestra/contrib/systemusers/admin.py b/orchestra/contrib/systemusers/admin.py index 64362957..7edf51f3 100644 --- a/orchestra/contrib/systemusers/admin.py +++ b/orchestra/contrib/systemusers/admin.py @@ -9,8 +9,8 @@ from orchestra.contrib.accounts.filters import IsActiveListFilter from .actions import set_permission, create_link from .filters import IsMainListFilter -from .forms import SystemUserCreationForm, SystemUserChangeForm -from .models import SystemUser +from .forms import SystemUserCreationForm, SystemUserChangeForm, WebappUserChangeForm, WebappUserCreationForm +from .models import SystemUser, WebappUsers class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): @@ -78,4 +78,34 @@ class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, Extende return super(SystemUserAdmin, self).has_delete_permission(request, obj) + +class WebappUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModelAdmin): + list_display = ( + 'username', 'account_link', 'shell', 'home', 'target_server' + ) + fieldsets = ( + (None, { + 'fields': ('account_link', 'username', 'password', ) + }), + (_("System"), { + 'fields': ('shell', 'home', 'target_server'), + }), + ) + add_fieldsets = ( + (None, { + 'fields': ('account_link', 'username', 'password1', 'password2') + }), + (_("System"), { + 'fields': ('shell', 'home', 'target_server'), + }), + ) + search_fields = ('username', 'account__username') + readonly_fields = ('account_link',) + change_readonly_fields = ('username', 'home', 'target_server') + add_form = WebappUserCreationForm + form = WebappUserChangeForm + ordering = ('-id',) + + admin.site.register(SystemUser, SystemUserAdmin) +admin.site.register(WebappUsers, WebappUserAdmin) \ No newline at end of file diff --git a/orchestra/contrib/systemusers/apps.py b/orchestra/contrib/systemusers/apps.py index e0db6346..d4bdedc1 100644 --- a/orchestra/contrib/systemusers/apps.py +++ b/orchestra/contrib/systemusers/apps.py @@ -2,6 +2,7 @@ import sys from django.apps import AppConfig from django.db.models.signals import post_migrate +from django.utils.translation import gettext_lazy as _ from orchestra.core import services @@ -11,11 +12,12 @@ class SystemUsersConfig(AppConfig): verbose_name = "System users" def ready(self): - from .models import SystemUser + from .models import SystemUser, WebappUsers services.register(SystemUser, icon='roleplaying.png') if 'migrate' in sys.argv and 'accounts' not in sys.argv: post_migrate.connect(self.create_initial_systemuser, dispatch_uid="orchestra.contrib.systemusers.apps.create_initial_systemuser") + services.register(WebappUsers, icon='roleplaying.png', verbose_name =_('WebApp User'), verbose_name_plural=_("Webapp users")) def create_initial_systemuser(self, **kwargs): from .models import SystemUser diff --git a/orchestra/contrib/systemusers/backends.py b/orchestra/contrib/systemusers/backends.py index debd12ff..67e8fe81 100644 --- a/orchestra/contrib/systemusers/backends.py +++ b/orchestra/contrib/systemusers/backends.py @@ -554,44 +554,7 @@ class UNIXUserControllerNewServers(ServiceController): done """) % context ) - else: - self.append(textwrap.dedent(""" - check_code=0 - # Ensure no processes running as user to modify/create - if ps -u %(user)s &> /dev/null; then - pkill -u %(user)s; sleep 3; - pkill -9 -u %(user)s; sleep 2; - fi - - # Update/create user state for %(user)s - if id %(user)s &> /dev/null; then - usermod %(user)s \\ - --password '%(password)s' \\ - --shell '%(shell)s' \\ - --groups '%(groups)s' || check_code=$? - else - useradd %(user)s --home '/%(user)s' \\ - --password '%(password)s' \\ - --shell '%(shell)s' \\ - --groups '%(groups)s' || check_code=$? - fi - - if [[ $check_code -ne 0 ]]; then - exit check_code - fi - - # Ensure homedir exists and has correct perms - mkdir -p %(home)s - chown %(user)s:%(user)s %(home)s - chmod 750 %(home)s - - # Create /chroots/$uid symlink into /home/$user.parent/webapps/ - uid=$(id -u "%(user)s") - ln -n -f -s %(mainuser_home)s/webapps /chroots/$uid - """) % context - ) - - + for member in settings.SYSTEMUSERS_DEFAULT_GROUP_MEMBERS: context['member'] = member self.append('usermod -a -G %(user)s %(member)s || exit_code=$?' % context) @@ -739,8 +702,6 @@ class UNIXUserControllerNewServers(ServiceController): if user.is_main: groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True)) groups.append("main-systemusers") - # groups = list(user.groups.values_list('username', flat=True)) - # groups.append("webapp-systemusers") return groups def get_context(self, user): @@ -756,5 +717,116 @@ class UNIXUserControllerNewServers(ServiceController): 'base_home': user.get_base_home(), 'mainuser_home': user.main.get_home(), } - # context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context + context['deleted_home'] = settings.SYSTEMUSERS_MOVE_ON_DELETE_PATH % context + return replace(context, "'", '"') + + + + +class WebappUserController(ServiceController): + """ + Basic UNIX system user/group support based on useradd, usermod, userdel and groupdel. + Autodetects and uses ACL if available, for better permission management. + """ + verbose_name = _("SFTP Webapp user") + model = 'systemusers.WebappUsers' + actions = ('save', 'delete',) + doc_settings = (settings, ( + 'SYSTEMUSERS_DEFAULT_GROUP_MEMBERS', + 'SYSTEMUSERS_MOVE_ON_DELETE_PATH', + 'SYSTEMUSERS_FORBIDDEN_PATHS' + )) + + def save(self, user): + context = self.get_context(user) + if not context['user']: + return + + self.append(textwrap.dedent(""" + # Update/create user state for %(user)s + if id %(user)s &> /dev/null; then + usermod %(user)s --home '/%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' + else + useradd_code=0 + useradd %(user)s --home '/%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' || useradd_code=$? + if [[ $useradd_code -eq 8 ]]; then + # User is logged in, kill and retry + pkill -u %(user)s; sleep 2 + pkill -9 -u %(user)s; sleep 1 + useradd %(user)s --home '/%(home)s' \\ + --password '%(password)s' \\ + --shell '%(shell)s' \\ + --groups '%(groups)s' + elif [[ $useradd_code -ne 0 ]]; then + exit $useradd_code + fi + fi + usermod -aG %(user)s %(parent)s + + # Ensure homedir exists and has correct perms + mkdir -p '%(webapp_path)s' || exit_code=1 + chown %(user)s:%(user)s %(webapp_path)s || exit_code=1 + chmod 750 '%(webapp_path)s' || exit_code=1 + + # Create /chroots/$uid symlink into /home/$user.parent/webapps/ + uid=$(id -u "%(user)s") + ln -n -f -s %(base_home)s/webapps /chroots/$uid || exit_code=1 + """) % context + ) + + + def delete(self, user): + context = self.get_context(user) + if not context['user']: + return + + self.append(textwrap.dedent("""\ + # Delete %(user)s user + uid=$(id -u "%(user)s") + + nohup bash -c 'sleep 2 && killall -u %(user)s -s KILL' &> /dev/null & + killall -u %(user)s || true + userdel %(user)s || exit_code=$? + groupdel %(group)s || exit_code=$? + + # Delete /chroots/$uid symlink into /home/$user.parent/webapps/ + rm /chroots/$uid + """) % context + ) + if context['deleted_home']: + self.append(textwrap.dedent("""\ + # Move home into SYSTEMUSERS_MOVE_ON_DELETE_PATH, nesting if exists. + mv '%(webapp_path)s' '%(deleted_home)s' || exit_code=$? + """) % context + ) + else: + self.append("rm -fr -- '%(webapp_path)s'" % context) + + + def get_groups(self, user): + groups = [] + groups = list(user.account.systemusers.exclude(username=user.username).values_list('username', flat=True)) + groups.append("webapp-systemusers") + return groups + + def get_context(self, user): + context = { + 'object_id': user.pk, + 'user': user.username, + 'group': user.username, + 'groups': ','.join(self.get_groups(user)), + 'password': user.password, #if user.active else '*%s' % user.password, + 'shell': user.shell, + 'home': user.home, + 'base_home': user.get_base_home(), + 'webapp_path': os.path.normpath(user.get_base_home() + "/webapps/" + user.home), + 'parent': user.get_parent(), + } + context['deleted_home'] = context['webapp_path'] + ".delete" return replace(context, "'", '"') diff --git a/orchestra/contrib/systemusers/forms.py b/orchestra/contrib/systemusers/forms.py index f95529bb..ab0b8c49 100644 --- a/orchestra/contrib/systemusers/forms.py +++ b/orchestra/contrib/systemusers/forms.py @@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from orchestra.forms import UserCreationForm, UserChangeForm +from orchestra.contrib.webapps.settings import WEBAPP_NEW_SERVERS from . import settings from .models import SystemUser @@ -162,3 +163,27 @@ class PermissionForm(LinkForm): ('r', _("Read only")), ('w', _("Write only")) )) + +# ---------------------------- + + +class WebappUserFormMixin(object): + + def __init__(self, *args, **kwargs): + super(WebappUserFormMixin, self).__init__(*args, **kwargs) + + def clean(self): + if not self.instance.pk: + server = self.cleaned_data.get('target_server') + if server: + if server.name not in WEBAPP_NEW_SERVERS: + self.add_error("target_server", _(f"{server} does not belong to the new servers")) + return self.cleaned_data + +class WebappUserCreationForm(WebappUserFormMixin, UserCreationForm): + pass + + +class WebappUserChangeForm(WebappUserFormMixin, UserChangeForm): + pass + diff --git a/orchestra/contrib/systemusers/migrations/0001_initial.py b/orchestra/contrib/systemusers/migrations/0001_initial.py index e15de8e5..30ea85fe 100644 --- a/orchestra/contrib/systemusers/migrations/0001_initial.py +++ b/orchestra/contrib/systemusers/migrations/0001_initial.py @@ -1,30 +1,32 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +# Generated by Django 2.2.28 on 2023-07-22 08:04 -from django.db import models, migrations -import orchestra.core.validators from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import orchestra.core.validators class Migration(migrations.Migration): + initial = True + dependencies = [ -# migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='SystemUser', fields=[ - ('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')), - ('username', models.CharField(validators=[orchestra.core.validators.validate_username], unique=True, help_text='Required. 64 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, verbose_name='username')), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')), ('password', models.CharField(max_length=128, verbose_name='password')), - ('home', models.CharField(blank=True, max_length=256, help_text='Starting location when login with this no-shell user.', verbose_name='home')), - ('directory', models.CharField(blank=True, max_length=256, help_text="Optional directory relative to user's home.", verbose_name='directory')), - ('shell', models.CharField(default='/dev/null', max_length=32, choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/bin/bash', '/bin/bash'), ('/bin/sh', '/bin/sh')], verbose_name='shell')), + ('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')), + ('directory', models.CharField(blank=True, help_text="Optional directory relative to user's home.", max_length=256, verbose_name='directory')), + ('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this account should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), -# ('account', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='systemusers', verbose_name='Account')), - ('groups', models.ManyToManyField(to='systemusers.SystemUser', blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='systemusers', to=settings.AUTH_USER_MODEL, verbose_name='Account')), + ('groups', models.ManyToManyField(blank=True, help_text='A new group will be created for the user. Which additional groups would you like them to be a member of?', to='systemusers.SystemUser')), ], ), ] diff --git a/orchestra/contrib/systemusers/migrations/0002_webappusers.py b/orchestra/contrib/systemusers/migrations/0002_webappusers.py new file mode 100644 index 00000000..1a552f5d --- /dev/null +++ b/orchestra/contrib/systemusers/migrations/0002_webappusers.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.28 on 2023-07-22 08:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import orchestra.core.validators + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orchestration', '__first__'), + ('systemusers', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='WebappUsers', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(help_text='Required. 32 characters or fewer. Letters, digits and ./-/_ only.', max_length=32, unique=True, validators=[orchestra.core.validators.validate_username], verbose_name='username')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('home', models.CharField(blank=True, help_text='Starting location when login with this no-shell user.', max_length=256, verbose_name='home')), + ('shell', models.CharField(choices=[('/dev/null', 'No shell, FTP only'), ('/bin/rssh', 'No shell, SFTP/RSYNC only'), ('/usr/bin/git-shell', 'No shell, GIT only'), ('/bin/bash', '/bin/bash')], default='/dev/null', max_length=32, verbose_name='shell')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL, verbose_name='Account')), + ('target_server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orchestration.Server', verbose_name='Server')), + ], + options={ + 'unique_together': {('username', 'target_server')}, + }, + ), + ] diff --git a/orchestra/contrib/systemusers/models.py b/orchestra/contrib/systemusers/models.py index bd6dc048..e51862ca 100644 --- a/orchestra/contrib/systemusers/models.py +++ b/orchestra/contrib/systemusers/models.py @@ -136,3 +136,43 @@ class SystemUser(models.Model): def get_home(self): return os.path.normpath(os.path.join(self.home, self.directory)) + + + +# ------------------ + +class WebappUsers(models.Model): + """ + System users for webapp + Username max_length determined by LINUX system user/group lentgh: 32 + """ + username = models.CharField(_("username"), max_length=32, unique=True, + help_text=_("Required. 32 characters or fewer. Letters, digits and ./-/_ only."), + validators=[validators.validate_username]) + password = models.CharField(_("password"), max_length=128) + account = models.ForeignKey('accounts.Account', verbose_name=_("Account"), + related_name='accounts', on_delete=models.CASCADE) + home = models.CharField(_("WebappDir"), max_length=256, blank=True, + help_text=_("name dir webapp /home/<main>/webapps/<DirName>"), + validators=[validators.validate_string_dir]) + shell = models.CharField(_("shell"), max_length=32, choices=settings.WEBAPPUSERS_SHELLS, + default='/dev/null') + target_server = models.ForeignKey('orchestration.Server', on_delete=models.CASCADE, + verbose_name=_("Server")) + + class Meta: + unique_together = ('username', 'target_server') + verbose_name = 'WebAppUser' + verbose_name_plural = 'WebappUsers' + + def __str__(self): + return self.username + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def get_base_home(self): + return os.path.normpath(self.account.main_systemuser.home) + + def get_parent(self): + return self.account.main_systemuser \ No newline at end of file diff --git a/orchestra/contrib/systemusers/settings.py b/orchestra/contrib/systemusers/settings.py index 5e931a02..044f58de 100644 --- a/orchestra/contrib/systemusers/settings.py +++ b/orchestra/contrib/systemusers/settings.py @@ -7,6 +7,13 @@ _names = ('user', 'username') _backend_names = _names + ('group', 'shell', 'mainuser', 'home', 'base_home') +WEBAPPUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS', + ( + ('/dev/null', _("No shell, SFTP only")), + ('/bin/bash', "/bin/bash"), + ), +) + SYSTEMUSERS_SHELLS = Setting('SYSTEMUSERS_SHELLS', ( ('/dev/null', _("No shell, FTP only")), diff --git a/orchestra/contrib/vps/backends.py.new b/orchestra/contrib/vps/backends.py.new new file mode 100644 index 00000000..2ead22c3 --- /dev/null +++ b/orchestra/contrib/vps/backends.py.new @@ -0,0 +1,135 @@ +import decimal +import textwrap + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor + +from . import settings + + +class ProxmoxOVZ(ServiceController): + model = 'vps.VPS' + + RESOURCES = ( + ('memory', 'mem'), + ('swap', 'swap'), + ('disk', 'disk') + ) + GET_PROXMOX_INFO = textwrap.dedent(""" + function get_vz_info () { + hostname=$1 + version=$(pveversion | cut -d '/' -f2 | cut -d'.' -f1) + if [[ $version -lt 2 ]]; then + conf=$(grep "CID\\|:$hostname:" /var/lib/pve-manager/vzlist | grep -B1 ":$hostname:") + CID=$(echo "$conf" | head -n1 | cut -d':' -f2) + CTID=$(echo "$conf" | tail -n1 | cut -d':' -f1) + node=$(pveca -l | grep "^\\s*$CID\\s*:" | awk {'print $3'}) + else + conf=$(grep -r "HOSTNAME=\\"$hostname\\"" /etc/pve/nodes/*/openvz/*.conf) + node=$(echo "${conf}" | cut -d"/" -f5) + CTID=$(echo "${conf}" | cut -d"/" -f7 | cut -d"\\." -f1) + fi + echo $CTID $node + }""") + + def prepare(self): + super(ProxmoxOVZ, self).prepare() + self.append(self.GET_PROXMOX_INFO) + + def get_vzset_args(self, context): + args = list(settings.VPS_DEFAULT_VZSET_ARGS) + for resource, arg_name in self.RESOURCES: + try: + allocation = context[resource] + except KeyError: + pass + else: + args.append('--%s %i' % (arg_name, allocation)) + return ' '.join(args) + + def run_ssh_commands(self, ssh_commands): + commands = '\n '.join(ssh_commands) + self.append(textwrap.dedent("""\ + cat << EOF | ssh root@${info[1]} + %s + EOF""") % commands + ) + + def save(self, vps): + # TODO create the container + context = self.get_context(vps) + self.append(textwrap.dedent(""" + info=( $(get_vz_info %(hostname)s) ) + echo "Managing ${info[@]}"\ + """) % context + ) + ssh_commands = [] + vzset_args = self.get_vzset_args(context) + if vzset_args: + context['vzset_args'] = vzset_args + ssh_commands.append("pvectl vzset ${info[0]} %(vzset_args)s" % context) + if hasattr(vps, 'password'): + context['password'] = vps.password.replace('$', '\\$') + ssh_commands.append(textwrap.dedent("""\ + echo 'root:%(password)s' \\ + | chroot /var/lib/vz/private/${info[0]} chpasswd -e""") % context + ) + self.run_ssh_commands(ssh_commands) + + def get_context(self, vps): + context = { + 'hostname': vps.hostname, + } + for resource, __ in self.RESOURCES: + try: + allocation = getattr(vps.resources, resource).allocated + except AttributeError: + pass + else: + context[resource] = allocation + return context + + +class ProxmoxOpenVZTraffic(ServiceMonitor): + model = 'vps.VPS' + resource = ServiceMonitor.TRAFFIC + monthly_sum_old_values = True + GET_PROXMOX_INFO = ProxmoxOVZ.GET_PROXMOX_INFO + + def prepare(self): + super(ProxmoxOpenVZTraffic, self).prepare() + self.append(self.GET_PROXMOX_INFO) + self.append(textwrap.dedent(""" + function monitor () { + object_id=$1 + hostname=$2 + info=( $(get_vz_info $hostname) ) + cat << EOF | ssh root@${info[1]} + vzctl exec ${info[0]} cat /proc/net/dev \\ + | grep venet0 \\ + | tr ':' ' ' \\ + | awk '{sum=\\$2+\\$10} END {printf ("$object_id %0.0f\\n", sum)}' + EOF + } + """) + ) + + def process(self, line): + """ diff with last stored state """ + object_id, value, state = super(ProxmoxOpenVZTraffic, self).process(line) + value = decimal.Decimal(value) + last = self.get_last_data(object_id) + if not last or last.state > value: + return object_id, value, value + return object_id, value-last.state, value + + def monitor(self, vps): + """ Get OpenVZ container traffic on a Proxmox cluster """ + context = self.get_context(vps) + self.append('monitor %(object_id)s %(hostname)s' % context) + + def get_context(self, vps): + return { + 'object_id': vps.id, + 'hostname': vps.hostname, + } diff --git a/orchestra/contrib/webapps/settings.py b/orchestra/contrib/webapps/settings.py index 19471c59..a5c5476d 100644 --- a/orchestra/contrib/webapps/settings.py +++ b/orchestra/contrib/webapps/settings.py @@ -282,3 +282,11 @@ WEBAPPS_CMS_CACHE_DIR = Setting('WEBAPPS_CMS_CACHE_DIR', '/tmp/orchestra_cms_cache', help_text="Server-side cache directori for CMS tarballs.", ) + +WEBAPP_NEW_SERVERS = Setting('WEBAPP_NEW_SERVERS', + ( + 'bookworm', + 'web-11.pangea.lan', + 'web-12.pangea.lan', + ) +) diff --git a/orchestra/core/validators.py b/orchestra/core/validators.py index f6e1fccd..2c6b46ce 100644 --- a/orchestra/core/validators.py +++ b/orchestra/core/validators.py @@ -177,3 +177,10 @@ def validate_phone(value, country): raise ValidationError(msg) if not phonenumbers.is_valid_number(number): raise ValidationError(msg) + +def validate_string_dir(value): + """ + A single non-empty line of free-form text with no whitespace. + """ + validators.RegexValidator('^[\_\-0-9a-z]+$', + _("Enter a valid name dir (spaceless lowercase text, number and _- )"), 'invalid')(value) diff --git a/orchestra/utils/html.py.bk b/orchestra/utils/html.py.bk new file mode 100644 index 00000000..f7b34b42 --- /dev/null +++ b/orchestra/utils/html.py.bk @@ -0,0 +1,37 @@ +import textwrap + +from django.templatetags.static import static +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from orchestra.utils.sys import run + + +def html_to_pdf(html, pagination=False): + """ converts HTL to PDF using wkhtmltopdf """ + context = { + 'pagination': textwrap.dedent("""\ + --footer-center "Page [page] of [topage]" \\ + --footer-font-name sans \\ + --footer-font-size 7 \\ + --footer-spacing 7""" + ) if pagination else '', + } + cmd = textwrap.dedent("""\ + PATH=$PATH:/usr/local/bin/ + xvfb-run -a -s "-screen 0 2480x3508x16" wkhtmltopdf -q \\ + --use-xserver \\ + %(pagination)s \\ + --margin-bottom 22 \\ + --margin-top 20 - - \ + """) % context + return run(cmd, stdin=html.encode('utf-8')).stdout + + +def get_on_site_link(url): + context = { + 'title': _("View on site %s") % url, + 'url': url, + 'image': format_html('', static('orchestra/images/view-on-site.png')), + } + return format_html('{image}', **context)