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)