diff --git a/.gitignore b/.gitignore index 19d9a8e..9025351 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ messages.xlsx # Translation binaries *.mo *.pot + +src/privatim/locale/de/LC_MESSAGES/privatim.mo +src/privatim/locale/fr/LC_MESSAGES/privatim.mo diff --git a/here.png b/here.png new file mode 100644 index 0000000..f928334 Binary files /dev/null and b/here.png differ diff --git a/requirements.txt b/requirements.txt index 1f1af9b..417babd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,6 +53,8 @@ email-validator==2.2.0 # via # privatim (setup.cfg) # privatim +et-xmlfile==1.1.0 + # via openpyxl fanstatic==1.4 # via # privatim (setup.cfg) @@ -81,6 +83,8 @@ idna==3.7 # via # email-validator # requests +lxml==5.3.0 + # via python-docx mako==1.3.5 # via # alembic @@ -100,6 +104,10 @@ nh3==0.2.18 # via # privatim (setup.cfg) # privatim +openpyxl==3.1.5 + # via + # privatim (setup.cfg) + # privatim packaging==24.1 # via zope-sqlalchemy pastedeploy==3.1.0 @@ -112,10 +120,11 @@ phonenumberslite==8.13.42 # via # privatim (setup.cfg) # privatim -pillow==10.4.0 +pillow==10.3.0 # via # privatim (setup.cfg) # privatim + # pyavatar # weasyprint plaster==1.1.2 # via @@ -126,10 +135,18 @@ plaster-pastedeploy==1.0.1 # privatim (setup.cfg) # privatim # pyramid +polib==1.2.0 + # via + # privatim (setup.cfg) + # privatim psycopg2==2.9.9 # via # privatim (setup.cfg) # privatim +pyavatar==0.1.6 + # via + # privatim (setup.cfg) + # privatim pycparser==2.22 # via cffi pydyf==0.10.0 @@ -177,6 +194,10 @@ pyramid-tm==2.5 # privatim python-dateutil==2.9.0.post0 # via arrow +python-docx==1.1.2 + # via + # privatim (setup.cfg) + # privatim python-magic==0.4.27 # via # privatim (setup.cfg) @@ -235,6 +256,7 @@ typing-extensions==4.12.2 # privatim (setup.cfg) # alembic # privatim + # python-docx # sqlalchemy urllib3==2.2.2 # via diff --git a/setup.cfg b/setup.cfg index 6a01183..bb0cb22 100644 --- a/setup.cfg +++ b/setup.cfg @@ -87,6 +87,7 @@ fanstatic.libraries = privatim:css = privatim.static:css_library console_scripts = + fix_img = privatim.cli.migrate_profile_pic:main add_user = privatim.cli.user:add_user # delete_user = privatim.cli.user:delete_user initialize_db = privatim.cli.initialize_db:main diff --git a/src/privatim/__init__.py b/src/privatim/__init__.py index 05ce859..1027296 100644 --- a/src/privatim/__init__.py +++ b/src/privatim/__init__.py @@ -53,6 +53,7 @@ def includeme(config: Configurator) -> None: )) smsdir = settings.get('sms.queue_path', '') config.registry.registerUtility(ASPSMSGateway(smsdir)) + config.include('pyramid_beaker') config.include('pyramid_chameleon') config.include('pyramid_layout') @@ -300,13 +301,16 @@ def upgrade(context: 'UpgradeContext'): # type: ignore[no-untyped-def] ) - # this needs to be added in the second run - context.operations.create_index( - 'idx_searchable_files_searchable_text_de_CH', - 'searchable_files', - ['searchable_text_de_CH'], - postgresql_using='gin', - ) + if not context.index_exists( + 'searchable_files', 'idx_searchable_files_searchable_text_de_CH' + ): + # this needs to be added in the second run + context.operations.create_index( + 'idx_searchable_files_searchable_text_de_CH', + 'searchable_files', + ['searchable_text_de_CH'], + postgresql_using='gin', + ) # Drop all existing comments and related tables context.drop_table('comments_for_consultations_comments') @@ -380,18 +384,26 @@ def upgrade(context: 'UpgradeContext'): # type: ignore[no-untyped-def] ), ) - context.operations.create_index( - context.operations.f('ix_consultations_deleted'), - 'consultations', - ['deleted'], - unique=False, - ) + if not context.index_exists( + 'consultations', 'ix_consultations_deleted' + ): + context.operations.create_index( + context.operations.f('ix_consultations_deleted'), + 'consultations', + ['deleted'], + unique=False, + ) - context.operations.create_index( - context.operations.f('ix_searchable_files_deleted'), - 'searchable_files', - ['deleted'], - unique=False, - ) + if not context.index_exists( + 'searchable_files', 'ix_searchable_files_deleted' + ): + context.operations.create_index( + context.operations.f('ix_searchable_files_deleted'), + 'searchable_files', + ['deleted'], + unique=False, + ) + + context.add_column('users', Column('tags', String(255), nullable=True)) context.commit() diff --git a/src/privatim/cli/migrate_profile_pic.py b/src/privatim/cli/migrate_profile_pic.py new file mode 100644 index 0000000..da28c9e --- /dev/null +++ b/src/privatim/cli/migrate_profile_pic.py @@ -0,0 +1,43 @@ +import click +from pyramid.paster import bootstrap +from pyramid.paster import get_appsettings + +from privatim.models import User +from privatim.models.profile_pic import get_or_create_default_profile_pic +from privatim.orm import get_engine, Base + + +@click.command() +@click.argument('config_uri') +def main(config_uri: str) -> None: + + env = bootstrap(config_uri) + settings = get_appsettings(config_uri) + engine = get_engine(settings) + Base.metadata.create_all(engine) + + with env['request'].tm: + dbsession = env['request'].dbsession + + # Update existing users and generate profile pics + default_pic = get_or_create_default_profile_pic(dbsession) + users = dbsession.query(User).all() + for user in users: + # Generate tags if not present + if not user.tags: + user.tags = ( + user.first_name[:1] + user.last_name[:1] + ).upper() or user.email[:2].upper() + + if ( + user.profile_pic is None or + user.profile_pic.content == default_pic.content + or user.profile_pic_id is None + ): + user.generate_profile_picture(dbsession) + + dbsession.flush() + + +if __name__ == '__main__': + main() diff --git a/src/privatim/cli/upgrade.py b/src/privatim/cli/upgrade.py index 70a2c2f..d7ff414 100644 --- a/src/privatim/cli/upgrade.py +++ b/src/privatim/cli/upgrade.py @@ -31,7 +31,7 @@ class UpgradeContext: def __init__(self, db: 'Session'): self.session = db - self.engine: 'Engine' = self.session.bind # type: ignore + self.engine: Engine = self.session.bind # type: ignore self.operations_connection = db._connection_for_bind( self.engine @@ -52,6 +52,11 @@ def drop_table(self, table: str) -> bool: return True return False + def index_exists(self, table_name: str, index_name: str) -> bool: + inspector = inspect(self.operations_connection) + indexes = inspector.get_indexes(table_name) + return any(index['name'] == index_name for index in indexes) + def has_column(self, table: str, column: str) -> bool: inspector = inspect(self.operations_connection) return column in {c['name'] for c in inspector.get_columns(table)} diff --git a/src/privatim/cli/user.py b/src/privatim/cli/user.py index 24a72fc..ca0fbf3 100644 --- a/src/privatim/cli/user.py +++ b/src/privatim/cli/user.py @@ -32,5 +32,6 @@ def add_user( return user = User(email=email, first_name=first_name, last_name=last_name) + user.generate_profile_picture(dbsession) user.set_password(password) dbsession.add(user) diff --git a/src/privatim/forms/constants.py b/src/privatim/forms/constants.py index 6cd5876..b05995a 100644 --- a/src/privatim/forms/constants.py +++ b/src/privatim/forms/constants.py @@ -57,3 +57,25 @@ ('ZG', 'ZG'), ('ZH', 'ZH'), ] + +# Color palette for avatar backgrounds +AVATAR_COLORS = [ + '#1abc9c', # Turquoise + '#2ecc71', # Emerald + '#3498db', # Peter River + '#9b59b6', # Amethyst + '#34495e', # Wet Asphalt + '#16a085', # Green Sea + '#27ae60', # Nephritis + '#2980b9', # Belize Hole + '#8e44ad', # Wisteria + '#2c3e50', # Midnight Blue + '#f1c40f', # Sunflower + '#e67e22', # Carrot + '#e74c3c', # Alizarin + '#95a5a6', # Concrete + '#f39c12', # Orange + '#d35400', # Pumpkin + '#c0392b', # Pomegranate + '#7f8c8d', # Asbestos +] diff --git a/src/privatim/forms/user_form.py b/src/privatim/forms/user_form.py index 87e4dca..0b271de 100644 --- a/src/privatim/forms/user_form.py +++ b/src/privatim/forms/user_form.py @@ -65,6 +65,11 @@ def validate_email(self, field: EmailField) -> None: validators=[Optional()], ) + tags = StringField( + _('Tags'), + validators=[Optional(), Length(min=1, max=3)], + ) + def populate_obj(self, obj: object) -> None: for name, field in self._fields.items(): diff --git a/src/privatim/locale/de/LC_MESSAGES/privatim.mo b/src/privatim/locale/de/LC_MESSAGES/privatim.mo index e80de3b..ea5ba80 100644 Binary files a/src/privatim/locale/de/LC_MESSAGES/privatim.mo and b/src/privatim/locale/de/LC_MESSAGES/privatim.mo differ diff --git a/src/privatim/locale/fr/LC_MESSAGES/privatim.mo b/src/privatim/locale/fr/LC_MESSAGES/privatim.mo index 93cef45..89f4fd9 100644 Binary files a/src/privatim/locale/fr/LC_MESSAGES/privatim.mo and b/src/privatim/locale/fr/LC_MESSAGES/privatim.mo differ diff --git a/src/privatim/models/user.py b/src/privatim/models/user.py index 59ab141..d3a8211 100644 --- a/src/privatim/models/user.py +++ b/src/privatim/models/user.py @@ -1,5 +1,9 @@ import uuid from functools import cached_property +from random import choice + + +from pyavatar import PyAvatar from pyramid.authorization import Allow from pyramid.authorization import Authenticated import bcrypt @@ -8,28 +12,31 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.session import object_session -from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column from sqlalchemy.orm import relationship from sqlalchemy import ForeignKey, select +from sqlalchemy.orm import Mapped +from privatim.forms.constants import AVATAR_COLORS from privatim.models import Group, WorkingGroup from privatim.models.profile_pic import get_or_create_default_profile_pic from privatim.orm.meta import UUIDStr as UUIDStrType from privatim.models.group import user_group_association from privatim.orm import Base from privatim.orm.meta import UUIDStrPK, str_256, str_128, str_32 +from privatim.models.file import GeneralFile from typing import TYPE_CHECKING if TYPE_CHECKING: from privatim.models.association_tables import MeetingUserAttendance from privatim.types import ACL + from sqlalchemy.orm import Session + from pyramid.interfaces import IRequest from privatim.models import Meeting from sqlalchemy import ScalarSelect from privatim.models.comment import Comment from privatim.models import Consultation - from privatim.models.file import GeneralFile class User(Base): @@ -40,6 +47,7 @@ def __init__( email: str, first_name: str = '', last_name: str = '', + tags: str = '', groups: list[Group] | None = None, ): self.id = str(uuid.uuid4()) @@ -47,6 +55,12 @@ def __init__( self.first_name = first_name self.last_name = last_name self.groups = groups or [] + self.tags = tags + + if tags: + self.tags = tags + else: + self.tags = self.generate_default_tags() id: Mapped[UUIDStrPK] @@ -58,19 +72,55 @@ def __init__( mobile_number: Mapped[str_128 | None] = mapped_column(unique=True) last_login: Mapped[datetime | None] last_password_change: Mapped[datetime | None] + tags: Mapped[str_32] profile_pic_id: Mapped[UUIDStrType | None] = mapped_column( ForeignKey('general_files.id', ondelete='SET NULL'), nullable=True ) - profile_pic: Mapped['GeneralFile | None'] = relationship( - 'GeneralFile', + profile_pic: Mapped[GeneralFile | None] = relationship( + GeneralFile, single_parent=True, passive_deletes=True, cascade='all, delete-orphan' ) - # the function of the user in the organization + def generate_default_tags(self) -> str: + initials = [] + if self.first_name: + initials.append(self.first_name[0].upper()) + if self.last_name: + initials.append(self.last_name[0].upper()) + return ''.join(initials) if initials else '' + + def generate_profile_picture(self, session: 'Session') -> None: + """ + Generate a profile picture based on user initials. + If no name is provided, use the first letter of the email. + Uses a predefined color palette for the background. + """ + initials = self.tags + + # Choose a random color from the palette + bg_color = choice(AVATAR_COLORS) # nosec[B311] + avatar = PyAvatar( + initials, size=250, char_spacing=35, color=bg_color, + ) + general_file = GeneralFile( + filename=f'{self.id}_avatar.png', + content=avatar.stream() + ) + session.add(general_file) + session.flush() # Flush to get the ID assigned + self.profile_pic = general_file + + def profile_pic_download_link(self, request: 'IRequest') -> str: + return ( + request.route_url('download_file', id=self.profile_pic_id) + if (self.profile_pic_id) + else request.static_url('privatim:static/default_profile_icon.png') + ) + function: Mapped[str | None] modified: Mapped[datetime | None] = mapped_column() @@ -154,7 +204,7 @@ def fullname(self) -> str: return ' '.join(parts) @property - def picture(self) -> 'GeneralFile': + def picture(self) -> GeneralFile: """ Returns the user's profile picture or the default picture. """ session = object_session(self) assert session is not None diff --git a/src/privatim/static/css/custom.css b/src/privatim/static/css/custom.css index 8270c69..3dfc0a6 100644 --- a/src/privatim/static/css/custom.css +++ b/src/privatim/static/css/custom.css @@ -673,3 +673,10 @@ input[type="color"]:focus, .badge.bg-consultation { background-color: #c6acae!important; } + +.profile-pic { + width: 150px; + height: 150px; + border-radius: 50%; + object-fit: cover; +} diff --git a/src/privatim/views/meetings.py b/src/privatim/views/meetings.py index daf6543..4420a3f 100644 --- a/src/privatim/views/meetings.py +++ b/src/privatim/views/meetings.py @@ -10,7 +10,7 @@ HTMLReportRenderer, ) from privatim.utils import datetime_format, strip_p_tags -from privatim.controls.controls import Button, Icon, IconStyle +from privatim.controls.controls import Button from pyramid.httpexceptions import ( HTTPFound, HTTPNotFound, @@ -134,20 +134,25 @@ def meeting_buttons(meeting: Meeting, request: 'IRequest') -> list[Button]: def user_list( request: 'IRequest', users: Sequence['MeetingUserAttendance'], title: str ) -> Markup: - """Returns an HTML list of users with links to their profiles and - checkbox on the right, with tooltips.""" + """ Returns an HTML list of users with profile pictures, links to their + profiles, and checkbox on the right, with tooltips.""" if not users: return Markup('') - user_items = tuple( Markup( '
${layout.format_date(activity.created, 'date')} ${layout.format_date(activity.created, 'time')}
-