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 %}