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( '
  • ' '
    ' - '{} {}' + '
    ' + '{} profile picture' + '
    ' + '{}' '
    ' '
    ' - ' ' @@ -155,7 +160,10 @@ def user_list( '
    ' '
  • ' ).format( - Icon('user', IconStyle.solid), + user.user.profile_pic_download_link( + request + ), + user.user.fullname, request.route_url("person", id=user.user_id), user.user.fullname, ( @@ -237,15 +245,22 @@ def meetings_view(context: WorkingGroup, request: 'IRequest') -> 'RenderData': def get_meeting_user_list( - context: WorkingGroup, request: 'IRequest', title: str + context: WorkingGroup, request: 'IRequest', title: str ) -> Markup: user_items = tuple( Markup( - '
  • {} ' + '
  • ' + '
    ' + '{} profile picture' + '
    ' '{}' '
  • ' ).format( - Icon('user', IconStyle.solid), + user.profile_pic_download_link(request), + user.fullname, request.route_url("person", id=user.id), user.fullname, ) diff --git a/src/privatim/views/people.py b/src/privatim/views/people.py index ee61a11..9fb5aa8 100644 --- a/src/privatim/views/people.py +++ b/src/privatim/views/people.py @@ -6,7 +6,7 @@ from privatim.controls.controls import Button from privatim.forms.user_form import UserForm -from privatim.i18n import _ +from privatim.i18n import _, translate from privatim.utils import strip_p_tags, maybe_escape from privatim.models import User, WorkingGroup @@ -52,6 +52,7 @@ def people_view(request: 'IRequest') -> 'RenderData': people_data.append({ 'id': user.id, 'name': f'{user.first_name} {user.last_name}', + 'download_link': user.profile_pic_download_link(request), 'url': request.route_url('person', id=user.id), 'buttons': button_html }) @@ -91,6 +92,7 @@ def person_view(context: User, request: 'IRequest') -> 'RenderData': return { 'user': user, + 'profile_pic_url': user.profile_pic_download_link(request), 'meeting_urls': meetings_dict, 'consultation_urls': consultation_dict } @@ -107,12 +109,15 @@ def add_user_view(request: 'IRequest') -> 'RenderDataOrRedirect': stmt = select(WorkingGroup).where( WorkingGroup.id.in_(form.groups.raw_data or ()) ) + tags = maybe_escape(form.tags.data) user = User( email=maybe_escape(form.email.data), first_name=maybe_escape(form.first_name.data), last_name=maybe_escape(form.last_name.data), + tags=tags if tags else '', groups=list(session.execute(stmt).scalars().unique()) ) + user.generate_profile_picture(session) session.add(user) session.flush() @@ -174,14 +179,15 @@ def delete_user_view( session.flush() full_name = f'{user.first_name} {user.last_name}' - message = _('Successfully deleted user: {full_name}.', mapping={ - 'full_name': full_name} - ) - request.messages.add(message, 'success') + message = _( + 'Successfully deleted user: {full_name}.', + mapping={'full_name': full_name} + ) + request.messages.add(translate(message, request.locale_name), 'success') if request.is_xhr: return { - 'success': message, + 'success': translate(message, request.locale_name), 'redirect_url': request.route_url('people'), } else: diff --git a/src/privatim/views/templates/activities.pt b/src/privatim/views/templates/activities.pt index c1cd0c3..82438f3 100644 --- a/src/privatim/views/templates/activities.pt +++ b/src/privatim/views/templates/activities.pt @@ -37,12 +37,39 @@

    ${layout.format_date(activity.created, 'date')} ${layout.format_date(activity.created, 'time')} - - by ${activity.creator.fullname} - by ${activity.creator.fullname} - by ${activity.user.fullname} - + + + by + ${activity.creator.fullname}'s avatar + ${activity.creator.fullname} + + + by + ${activity.creator.fullname}'s avatar + ${activity.creator.fullname} + + by + ${activity.user.fullname}'s avatar + ${activity.user.fullname} + + +

    diff --git a/src/privatim/views/templates/people.pt b/src/privatim/views/templates/people.pt index cdc2441..ff5e28d 100644 --- a/src/privatim/views/templates/people.pt +++ b/src/privatim/views/templates/people.pt @@ -29,7 +29,10 @@ - ${person.name} +
    diff --git a/src/privatim/views/templates/person.pt b/src/privatim/views/templates/person.pt index b314fee..183caae 100644 --- a/src/privatim/views/templates/person.pt +++ b/src/privatim/views/templates/person.pt @@ -3,7 +3,10 @@ xmlns="http://www.w3.org/1999/xhtml" xmlns:metal="http://xml.zope.org/namespaces/metal" i18n:domain="privatim"> +
    + ${user.fullname}'s profile picture

    ${user.fullname}

    • ${user.email}
    • diff --git a/src/pyavatar/__init__.py b/src/pyavatar/__init__.py new file mode 100644 index 0000000..033564b --- /dev/null +++ b/src/pyavatar/__init__.py @@ -0,0 +1,293 @@ +""" +Modified by: cyrill + - Supports more than one character in the avatar, which was not + supported before. + + +Pyavatar Library +~~~~~~~~~~~~~~~~ + +Pyavatar is a library, written in Python, to generate simple default +user avatars to use in a web application or elsewhere. + +:copyright: (c) 2020 by Matthieu Petiteau. +:license: MIT, see LICENSE for more details. +""" + +import os +import random +from base64 import b64encode +from enum import Enum, IntEnum +from io import BytesIO +from typing import TypeAlias + +from PIL import Image, ImageDraw, ImageFont + + +__all__ = ( + "PyAvatar", + "PyAvatarError", + "RenderingSizeError", + "FontpathError", + "FontExtensionNotSupportedError", + "ImageExtensionNotSupportedError", +) + + +class PyAvatarError(Exception): + """Base PyAvatar error.""" + + def __init__(self, value: str, message: str = "", info: str = "") -> None: + self.value = value + self.message = message or self.__doc__ + self.info = info + super().__init__(self.message) + + def __str__(self) -> str: + return f"{self.value} -> {self.message} {self.info}".strip() + + +class RenderingSizeError(PyAvatarError): + """Error with the chosen rendering size.""" + + +class FontpathError(PyAvatarError): + """Cannot find a font file at this location.""" + + +class FontExtensionNotSupportedError(PyAvatarError): + """Font file extension not supported.""" + + +class ImageExtensionNotSupportedError(PyAvatarError): + """Image extension not supported.""" + + +def csv(str_enum: type[Enum]) -> str: + assert issubclass(str_enum, str) and issubclass(str_enum, Enum) + return ", ".join(list(str_enum)) + + +class SupportedImageFmt(str, Enum): + PNG = "png" + JPEG = "jpeg" + ICO = "ico" + + +class SupportedFontExt(str, Enum): + TTF = ".ttf" + OTF = ".otf" + + +class SupportedPixelRange(IntEnum): + MIN = 50 + MAX = 650 + + +_DEFAULT_IMAGE_SIZE = 120 +_DEFAULT_FILEPATH = f"{os.getcwd()}/avatar.png" +_DEFAULT_FONT_FILEPATH = os.path.join( + os.path.dirname(__file__), "font/Lora.ttf" +) + +_HexColor: TypeAlias = str +_RGBColor: TypeAlias = tuple[int, int, int] + + +class PyAvatar: + """Generate a default avatar from a given string input. + + :param text: Input text to use in the avatar. + :param size: (optional) Integer, size in pixel of the avatar. + :param fontpath: (optional) Filepath to the font file to use. + :param color: (optional) hex or rgb color code for the background. + :type color: string or tuple + :param capitalize: (optional) Boolean, capitalize the first letter. + :type capitalize: bool + + Usage:: + >>> from pyavatar import PyAvatar + >>> avatar = PyAvatar("smallwat3r", size=250) + >>> avatar.color + (191, 91, 81) + >>> avatar.change_color() + >>> avatar.color + (203, 22, 126) + >>> avatar.stream("png") + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\xfa\x00\x00 ...' + >>> avatar.base64_image("jpeg") + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBg ...' + >>> import os + >>> avatar.save(f"{os.getcwd()}/me.png") + """ + + def __init__( + self, + text: str, + size: int = _DEFAULT_IMAGE_SIZE, + fontpath: str = _DEFAULT_FONT_FILEPATH, + color: _HexColor | _RGBColor | None = None, + capitalize: bool = True, + char_spacing: int = 0, + ): + self.text = text + if capitalize: + self.text = self.text.upper() + self.size = size + self.fontpath = fontpath + self.color = color or self._random_color() + self.char_spacing = char_spacing + self.image = self.__generate_avatar() + + def __str__(self) -> str: + return f"{self.text} {self.size}x{self.size} {self.color}" + + @property + def text(self) -> str: + return self._text + + @text.setter + def text(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError("Attribute `text` must be a string.") + if len(value) > 3: + raise ValueError("Text must be 3 characters or less.") + self._text = value[:3] # Limit to the first three characters + + @property + def char_spacing(self) -> int: + return self._char_spacing + + @char_spacing.setter + def char_spacing(self, value: int) -> None: + if not isinstance(value, int): + raise TypeError("Attribute `char_spacing` must be an integer.") + if value < 0: + raise ValueError("Character spacing must be non-negative.") + self._char_spacing = value + + @property + def size(self) -> int: + return self._size + + @size.setter + def size(self, value: int) -> None: + if not isinstance(value, int): + raise TypeError("Attribute `size` must be an integer.") + if value < SupportedPixelRange.MIN or value > SupportedPixelRange.MAX: + raise RenderingSizeError( + str(value), + ( + "Size must fit within range " + f"min={SupportedPixelRange.MIN} " + f"max={SupportedPixelRange.MAX}." + ), + ) + self._size = value + + @property + def fontpath(self) -> str: + return self._fontpath + + @fontpath.setter + def fontpath(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError("Attribute `fontpath` must be a string.") + if not os.path.exists(value): + raise FontpathError(value) + if not value.lower().endswith(tuple(SupportedFontExt)): + raise FontExtensionNotSupportedError( + os.path.basename(value), + info=f"Supported extensions: {csv(SupportedFontExt)}.", + ) + self._fontpath = value + + @staticmethod + def _random_color() -> _RGBColor: + return ( + random.randint(0, 255), # nosec + random.randint(0, 255), # nosec + random.randint(0, 255), # nosec + ) + + def __generate_avatar(self) -> Image.Image: + image = Image.new( + mode="RGB", size=(self.size, self.size), color=self.color + ) + font_size = int( + 0.9 * self.size / len(self.text) + ) # Slightly reduced to accommodate spacing + font = ImageFont.truetype(self.fontpath, size=font_size) + draw = ImageDraw.Draw(image) + + # Calculate total width including spacing + total_width = sum( + draw.textlength(char, font) for char in self.text + ) + self.char_spacing * (len(self.text) - 1) + total_height = max( + font.getbbox(char)[3] - font.getbbox(char)[1] for char in self.text + ) + + # Calculate starting position + start_x = (self.size - total_width) / 2 + start_y = (self.size - total_height) / 2 - self.size * 0.1 + + # Draw each character with spacing + for char in self.text: + char_width = draw.textlength(char, font) + draw.text((start_x, start_y), char, font=font, fill='white') + start_x += char_width + self.char_spacing + + return image + + def change_color(self, color: _HexColor | _RGBColor | None = None) -> None: + """Redraw the avatar with a new background color. + + :param color: (optional) hex or rgb color code for the background. + :type color: string or tuple + """ + self.color = color or self._random_color() + self.image = self.__generate_avatar() + + def save(self, filepath: str = _DEFAULT_FILEPATH) -> None: + """Save the avatar under a given file path. + + :param filepath: (optional) Filepath where the avatar will be saved. + """ + extension = os.path.splitext(filepath)[1].split(".")[1] + if extension not in set(SupportedImageFmt): + raise ImageExtensionNotSupportedError( + os.path.basename(filepath), + info=f"Supported formats: {csv(SupportedImageFmt)}.", + ) + directory = os.path.dirname(filepath) + if not os.path.exists(directory): + os.makedirs(directory) + self.image.save(filepath, optimize=True) + + def stream( + self, filetype: SupportedImageFmt = SupportedImageFmt.PNG + ) -> bytes: + """Save the avatar in a bytes array. + + :param filetype: (optional) Avatar file format. + :rtype: bytes + """ + if filetype.lower() not in set(SupportedImageFmt): + raise ImageExtensionNotSupportedError( + filetype, info=f"Supported formats: {csv(SupportedImageFmt)}." + ) + stream = BytesIO() + self.image.save(stream, format=filetype.value, optimize=True) + return stream.getvalue() + + def base64_image( + self, filetype: SupportedImageFmt = SupportedImageFmt.PNG + ) -> str: + """Save the avatar as a base64 image. + + :param filetype: (optional) Avatar file format. + :rtype: str + """ + encoded_image = b64encode(self.stream(filetype)).decode("utf-8") + return f"data:image/{filetype.value};base64,{encoded_image}" diff --git a/src/pyavatar/font/Lora.ttf b/src/pyavatar/font/Lora.ttf new file mode 100755 index 0000000..5ad50b2 Binary files /dev/null and b/src/pyavatar/font/Lora.ttf differ diff --git a/tests/models/test_user.py b/tests/models/test_user.py index 81a28b7..afb5a2a 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -1,3 +1,7 @@ +from io import BytesIO +from PIL import Image + + from privatim.models import User, WorkingGroup, Group, GeneralFile from sqlalchemy import select @@ -113,3 +117,17 @@ def test_user_default_profile_picture(session): stored_user_with_custom_pic.profile_pic.filename == 'custom_profile_pic.jpg' ) + + +def test_generate_profile_picture(session): + user = User( + email='john.doe@example.com', first_name='John', last_name='Doe' + ) + user.generate_profile_picture(session) + + assert isinstance(user.profile_pic, GeneralFile) + assert user.profile_pic.filename == f'{user.id}_avatar.png' + + img = Image.open(BytesIO(user.profile_pic.content)) + assert img.size == (250, 250) + assert img.mode == 'RGB'