~williamvds/fossfund

f5b7009bf5f46d42a1137ec13c2b6839d9fda7f4 — williamvds 5 years ago 9f8ace5
add organisations pages
M config.yaml => config.yaml +1 -0
@@ 2,6 2,7 @@ host: lvh.me
port: 8080
dev: true
projectsPerPage: 20
organisationsPerPage: 20
maxLogoSize: 1048576
sessionSecret: mrzmm3GbksRN2bAhKA25YDZDm-lze7JGuTR3j81RC80=
db:

M fossfund/database/model/__init__.py => fossfund/database/model/__init__.py +2 -0
@@ 3,6 3,7 @@ for interacting with database'''
import asyncio

from .project import Project
from .organisation import Organisation
from .record import (
    NULL, InvalidRecordError, InvalidStateError, NotUniqueError
)


@@ 13,4 14,5 @@ __all__ = [
    'NotUniqueError',
    'NULL',
    'Project',
    'Organisation',
]

A fossfund/database/model/logo.py => fossfund/database/model/logo.py +94 -0
@@ 0,0 1,94 @@
'''Implementation of RecordWithLogo model'''
import os

from .record import Record
from ...extends import AppError, AppFatalError, Config

class RecordWithLogo(Record):
    '''An extended Record which provides methods for storing and deleting logos
    '''

    #: Filesystem path under which to store logos
    _logoPathRoot: str = None

    @classmethod
    def saveLogo(cls, _id: int, logo: bytes, mime: str):
        '''Save the logo image of a project

        :param _id: ID of project to set logo of
        :param logo: Logo data to store
        :param mime: MIME type of the image
        '''
        if not mime.startswith('image/'):
            raise AppError('Logo is not an image')

        if len(logo) > Config.maxLogoSize:
            raise AppError(
                f'Logo too large - max allowed is {Config.maxLogoSize/1024}KB')

        logoPath = os.path.join(cls._logoPathRoot, str(_id))
        try:
            fd = os.open(logoPath, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o660)
            with os.fdopen(fd, 'wb') as logoFile:
                logoFile.write(logo)
        except IOError:
            raise AppError('Couldn\'t save logo image')

    @classmethod
    async def setLogoID(cls, _id: int, logo: bytes, mime: str):
        '''Save the logo image of a record, then update the record accordingly

        :param _id: ID of record to set logo of
        :param logo: Logo data to store
        :param mime: MIME type of the image
        '''

        cls.saveLogo(_id, logo, mime)
        await cls.updateID(_id, {'logo': True})

    @property
    def _logoPath(self):
        return os.path.join(self._logoPathRoot, str(self._id))

    def _saveCallback(self):
        '''Save the logo image once the record has been successfully saved
        '''
        super()._saveCallback()

        if self.logo and self._logoData:
            self.saveLogo(self._id, self._logoData, self._logoMime)
        elif not self.logo and os.path.isfile(self._logoPath):
            try:
                os.remove(self._logoPath)
            except OSError:
                AppFatalError('Failed to remove logo')

        self._logoData = None
        self._logoMime = None

    def setLogo(self, logo: bytes, mime: str):
        '''Save a logo image for this record

        :param logo: Logo data to store
        :param mime: MIME type of the image
        '''
        self._logoData = logo
        self._logoMime = mime
        self.logo = True

    def removeLogo(self):
        '''Remove the logo image of a record
        '''
        self.logo = False

    def __init__(self, data: dict, committed: bool = False):
        #: Whether this record has an uploaded logo
        self.logo: bool = None

        #: Logo data that will be written once this record is saved or updated
        self._logoData = None

        #: MIME type of the record's logo data
        self._logoMime = None

        super().__init__(data, committed)

A fossfund/database/model/organisation.py => fossfund/database/model/organisation.py +60 -0
@@ 0,0 1,60 @@
'''Implementation of the Organisation model'''
from typing import Awaitable, Union, List

from sqlalchemy import Table

from ...extends import AppError, AppFatalError, Config
from ..schema import organisations
from .logo import RecordWithLogo

class Organisation(RecordWithLogo):
    '''A sponsoring organisation that supports the development of projects
    '''

    _table: Table = organisations
    _primaryKey: str = 'orgID'
    _logoPathRoot: str = Config.organisationLogoDir

    @classmethod
    async def get(cls, value: int) -> Awaitable[Union['Organisation', None]]:
        '''Find a record by its ID

        :returns: found record, or :type:`NoneType`
        '''
        return await cls._findID(value)

    @classmethod
    async def getList(cls, page: int) -> List['Organisation']:
        '''Get the list of organisations

        :param page: offset into the organisations list to retreive

        todo:: sorting methods
        '''
        if page < 1:
            raise AppFatalError(f'Invalid page: {page}')

        res = await cls._find(
            cls._table.select() \
            .limit(Config.projectsPerPage) \
            .offset((page - 1) * Config.organisationsPerPage))

        if not res:
            if page > 1:
                raise AppFatalError(f'Page {page} does not exist')
            else:
                raise AppError('There are no matching organisations')

        return res

    def __init__(self, data: dict, committed: bool = False):
        #: Unique ID
        self.orgID: int = None

        #: Name
        self.name: str = None

        #: Description
        self.desc: str = None

        super().__init__(data, committed)

M fossfund/database/model/project.py => fossfund/database/model/project.py +4 -85
@@ 1,5 1,4 @@
'''Implementation of the Project model'''
import asyncio
from typing import Awaitable, Union, List
import os



@@ 7,14 6,16 @@ from sqlalchemy import Table

from ...extends import AppError, AppFatalError, Config
from ..schema import projects
from .record import Record, Null
from .logo import RecordWithLogo
from .record import Null

class Project(Record):
class Project(RecordWithLogo):
    '''A free and open source project that is listed on the website
    '''

    _table: Table = projects
    _primaryKey: str = 'projID'
    _logoPathRoot: str = Config.projectLogoDir

    #: Unique ID of the project, primary key
    projID: int = None


@@ 31,32 32,6 @@ class Project(Record):
    #: URL to the project's own website
    homepage: str = None

    #: Whether this project has an uploaded logo
    logo: bool = None

    @staticmethod
    def saveLogo(_id: int, logo: bytes, mime: str):
        '''Save the logo image of a project

        :param _id: ID of project to set logo of
        :param logo: Logo data to store
        :param mime: MIME type of the image
        '''
        if not mime.startswith('image/'):
            raise AppError('Logo is not an image')

        if len(logo) > Config.maxLogoSize:
            raise AppError(
                f'Logo too large - max allowed is {Config.maxLogoSize/1024}KB')

        logoPath = os.path.join(Config.projectLogoDir, str(_id))
        try:
            fd = os.open(logoPath, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o660)
            with os.fdopen(fd, 'wb') as logoFile:
                logoFile.write(logo)
        except IOError:
            raise AppError('Couldn\'t save logo image')

    @classmethod
    async def get(cls, value: int) -> Awaitable[Union['Project', None]]:
        '''Find a project by its ID


@@ 90,62 65,6 @@ class Project(Record):
        return res

    @classmethod
    async def setLogoID(cls, _id: int, logo: bytes, mime: str):
        '''Save the logo image of a project, then update the record accordingly

        :param _id: ID of project to set logo of
        :param logo: Logo data to store
        :param mime: MIME type of the image
        '''

        cls.saveLogo(_id, logo, mime)
        await cls.updateID(_id, {'logo': True})

    def __init__(self, data: dict, committed: bool = False):
        super().__init__(data, committed)

        #: Logo data that will be written once this project is saved or updated
        self._logoData = None

        #: MIME type of the project logo data
        self._logoMime = None

    @property
    def _logoPath(self):
        return os.path.join(Config.projectLogoDir, str(self._id))

    def _saveCallback(self):
        '''Save the logo image once the project has been successfully saved
        '''
        super()._saveCallback()

        if self.logo and self._logoData:
            self.saveLogo(self._id, self._logoData, self._logoMime)
        elif not self.logo and os.path.isfile(self._logoPath):
            try:
                os.remove(self._logoPath)
            except OSError:
                AppFatalError('Failed to remove project logo')

        self._logoData = None
        self._logoMime = None

    def setLogo(self, logo: bytes, mime: str):
        '''Save a logo image for this project

        :param logo: Logo data to store
        :param mime: MIME type of the image
        '''
        self._logoData = logo
        self._logoMime = mime
        self.logo = True

    def removeLogo(self):
        '''Remove the logo image of a project
        '''
        self.logo = False

    @classmethod
    async def findID(cls, value: int) -> Union['Project', None]:
        return await cls._findID(value)


M fossfund/extends.py => fossfund/extends.py +3 -2
@@ 125,9 125,10 @@ class Config(metaclass=ConfigMeta):
            os.makedirs(cls.staticDir, 0o755)

        cls.projectLogoDir = os.path.join(cls.staticDir, 'project')
        cls.organisationLogoDir = os.path.join(cls.staticDir, 'organisation')

        if not os.path.exists(cls.projectLogoDir):
            os.makedirs(cls.projectLogoDir, 0o750)
        os.makedirs(cls.projectLogoDir, 0o750, exist_ok=True)
        os.makedirs(cls.organisationLogoDir, 0o750, exist_ok=True)

# TODO: bootstrap Config within main
path = os.path.join(os.path.dirname(__file__), '../config.yaml')

A fossfund/organisation/__init__.py => fossfund/organisation/__init__.py +160 -0
@@ 0,0 1,160 @@
'''Controllers for /organisation'''
from attrdict import AttrDict
from aiohttp.web import Request, HTTPFound as redirect
from aiohttp_route_decorator import RouteCollector
from aiohttp_jinja2 import template

from .. import database
from ..database.model import Organisation
from ..extends import AppError, error, Config

route = RouteCollector(prefix='/organisation')

@route('')
@template('organisation/list.html')
async def projectList(req: Request):
    '''/organisation

    :contents:
        * `add organisation` link (/organisation/add)
        * list of existing organisations, paginated

    todo:: sorting?
    '''
    page = int(req.query.get('page', 1))

    res = await Organisation.getList(page)

    return {'title': 'Organisations', 'page': page, 'res': res}

@route('/add')
@template('organisation/form.html')
async def projectAddForm(req: Request):
    '''/organisation/add

    :contents:
        * organisation form, empty
    '''
    return {'title': 'Add an organisation'}

@route('/add', method='POST')
async def projectAdd(req: Request):
    '''/organisation/add (POST)
    Save a new organisation with the POSTed information
    ``orgID`` field is ignored
    ``logo`` is set conditionally based on whether a file was uploaded

    :redirect: created organisation's page

    todo::
        * check permissions
        * logging/moderation
    '''
    vals = AttrDict(await req.post())
    if 'orgID' in vals and int(vals.orgID) == 0: vals.orgID = None

    org = Organisation(vals)

    if vals.logo:
        org.setLogo(vals.logo.file.read(), vals.logo.content_type)
    else:
        org.removeLogo()

    await org.save()

    return redirect(f'/organisation/{org.orgID}')

@route('/edit/{orgID}')
@template('organisation/form.html')
async def projectEditForm(req: Request):
    '''/organisation/edit

    :contents:
        * organisation form, filled with existing information
        * description of form fields
        * guidelines

    todo:: logo deletion
    '''
    orgID = int(req.match_info['orgID'])
    org = await Organisation.get(orgID)

    return {'title': f'Editing {org.name}',
            'org': org}

@route('/edit', method='POST')
async def projectEdit(req: Request):
    '''/organisation/edit (POST)
    Update organisation using POSTed information
    ``logo``

    :redirect: the edited organisation's page

    todo::
        * check permissions
        * logging/moderation
        * logo deletion
    '''
    vals = AttrDict(await req.post())
    if 'orgID' in vals and int(vals.orgID) == 0: vals.orgID = None

    org = Organisation(vals, True)

    if 'removeLogo' in vals:
        org.removeLogo()
    elif vals.logo:
        org.setLogo(vals.logo.file.read(), vals.logo.content_type)
    else:
        org.logo = None

    # pylint complains about the `dml` parameter being unspecified
    # pylint: disable=no-value-for-parameter
    await org.save()

    return redirect(f'/organisation/{vals.orgID}')

@route('/remove/{orgID}')
async def projectRemove(req: Request):
    '''/organisation/remove
    Delete organisation by ID

    :redirect: /organisation

    todo:: authentication
    '''
    try:
        orgID = int(req.match_info['orgID'])
    except ValueError:
        raise AppError('That organisation does not exist')

    await Organisation.deleteID(orgID)

    return redirect('/organisation')

@route('/{orgID}')
@template('organisation/view.html')
async def organisation(req: Request):
    '''/organisation/(id)

    :contents:
        * organisation name, logo, description
        * `edit organisation` link (/organisation/edit)
        * `delete organisation` link (/organisation/remove)

    todo::
        * only show ID in debug/development mode
        * organisation website(s)
        * donation links
        * estimated income?
    '''
    try:
        orgID = int(req.match_info['orgID'])
    except ValueError:
        return error(req)

    res = await Organisation.get(orgID)

    if not res:
        return error(req)

    return {'title': res.name, 'org': res}

A fossfund/organisation/form.html => fossfund/organisation/form.html +16 -0
@@ 0,0 1,16 @@
{% extends "templates/layout.html" %}
{% block main %}
<form action=/organisation/{{'edit' if org else 'add'}} method=post
  enctype=multipart/form-data>
    <label>Name <input type=text name=name {%- if org %} value="{{org.name}}"{% endif %}></label>
    <label>Description <input type=text name=desc {%- if org and org.desc %} value="{{org.desc}}"{% endif %}></label>
    <label>Logo <input type=file name=logo></label>
    {% if org and org.logo %}
      <label>Remove logo
        <input type=checkbox name=removeLogo />
      </label>
    {% endif %}
    <input type=submit value="Submit{{' changes' if org else ''}}">
    {% if org %}<input type=number name=orgID value="{{org.orgID}}" hidden>{% endif %}
</form>
{% endblock %}

A fossfund/organisation/list.html => fossfund/organisation/list.html +14 -0
@@ 0,0 1,14 @@
{% extends "templates/layout.html" %}
{% from "templates/macros.html" import organisationLayout %}
{% block main %}
  <h1>{{title}}</h1>
  <a href=/organisation/add>Add project</a>
  <p>Page {{page}}</p>
  {% if res %}
    {% for row in res %}
      {{organisationLayout(row)}}
    {% endfor %}
  {% else %}
    <em>No projects found</em>
  {% endif %}
{% endblock %}

A fossfund/organisation/view.html => fossfund/organisation/view.html +7 -0
@@ 0,0 1,7 @@
{% extends "templates/layout.html" %}
{% from "templates/macros.html" import organisationLayout %}
{% block main %}
  <a href=/organisation/edit/{{org.orgID}}>Edit</a>
  <a href=/organisation/remove/{{org.orgID}}>Remove</a>
  {{organisationLayout(org, false)}}
{% endblock %}

M fossfund/routes.py => fossfund/routes.py +2 -1
@@ 1,7 1,7 @@
'''Handles routing for the application'''
from aiohttp.web import UrlDispatcher

from . import index, user, project
from . import index, user, project, organisation

def addRoutes(router: UrlDispatcher):
    '''Add the :class:`UrlDispatcher` routers of the website submodules to given


@@ 11,4 11,5 @@ def addRoutes(router: UrlDispatcher):
    '''
    user.route.add_to_router(router)
    project.route.add_to_router(router)
    organisation.route.add_to_router(router)
    index.route.add_to_router(router)

M fossfund/templates/layout.html => fossfund/templates/layout.html +1 -1
@@ 9,7 9,7 @@
        <a href=/>fossfund</a>
        <nav>
          <ul>
            {% for href, name in [('/project', 'Projects'), ('/organisations', 'Organisations')] %}
            {% for href, name in [('/project', 'Projects'), ('/organisation', 'Organisations')] %}
            <li><a {%- if request.path == href %} class=current{% endif %} href={{href}}>{{name}}</a>
            {% endfor %}
            <li>

M fossfund/templates/macros.html => fossfund/templates/macros.html +15 -0
@@ 20,3 20,18 @@
  {% endif %}
</div>
{% endmacro %}

{% macro organisationLayout(rec, link=True) %}
<div class=organisation>
  {% if rec.logo %}
    <img src="/static/organisation/{{rec.orgID}}" alt="Logo of {{rec.name}}">
  {% endif %}
  {% if link %}<a href="/organisation/{{rec.orgID}}">{% endif %}
    <h2>{{rec.name}}</h2>
  {% if link %}</a>{% endif %}
  {% if Config.dev -%}
    <span>#{{rec.orgID}}</span>
  {% endif %}
  <p>{{rec.desc or ''}}{% if not rec.desc %}<em>No description</em>{% endif %}</p>
</div>
{% endmacro %}