diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..f958b30 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,25 @@ +before_script: + - docker info + +build_backend_test_image: + stage: build + script: + - docker build ./backend/ -t docker.maxiv.lu.se/elogy_backend-test + - docker push docker.maxiv.lu.se/elogy_backend-test + only: + - master + +build_frontend_test_image: + stage: build + script: + - docker build ./frontend/ -t docker.maxiv.lu.se/elogy_frontend-test + - docker push docker.maxiv.lu.se/elogy_frontend-test + only: + - master + +deploy_test_image: + stage: deploy + script: + - 'curl -H "Authorization: Bearer $AWX_TOKEN" -XPOST https://ansible.maxiv.lu.se/api/v2/job_templates/22/launch/' + only: + - master diff --git a/backend/backend/api/entries.py b/backend/backend/api/entries.py index 712cecf..37bd582 100644 --- a/backend/backend/api/entries.py +++ b/backend/backend/api/entries.py @@ -1,4 +1,5 @@ import logging +from slugify import slugify from flask import request, send_file from flask_restful import Resource, marshal, marshal_with, abort @@ -8,7 +9,7 @@ from ..db import Entry, Logbook, EntryLock from ..attachments import handle_img_tags -from ..export import export_entries_as_pdf +from ..export import export_entries_as_pdf, export_entries_as_html from ..actions import new_entry, edit_entry from . import fields, send_signal @@ -196,6 +197,16 @@ def get(self, args, logbook_id=None): as_attachment=True, attachment_filename=("{logbook.name}.pdf" .format(logbook=logbook))) + elif args.get("download") == "html": + # return a PDF version + # TODO: not sure if this belongs in the API + html = export_entries_as_html(logbook, entries) + if html is None: + abort(400, message="Could not create HTML!") + return send_file(html, mimetype="text/html", + as_attachment=False, + attachment_filename=(slugify("{logbook.name}.html" + .format(logbook=logbook)))) return marshal(dict(logbook=logbook, entries=list(entries)), fields.entries) diff --git a/backend/backend/export.py b/backend/backend/export.py index 2ff416a..53c5ef0 100644 --- a/backend/backend/export.py +++ b/backend/backend/export.py @@ -1,4 +1,5 @@ from tempfile import NamedTemporaryFile +from slugify import slugify try: import pdfkit @@ -52,3 +53,35 @@ def export_entries_as_pdf(logbook, entries): # https://github.com/wkhtmltopdf/wkhtmltopdf/issues/2051 pass return f.name + +def export_entries_as_html(logbook, entries): + + """ + Super basic "proof-of-concept" html export + No proper formatting, and does not embed images. + """ + +# export = "" + entries_html = [ + """ +
Created at: {created_at}
+
Title: {title}
+
Authors: {authors}
+
{content}
+
+ """.format(title=entry.title or "(No title)", + authors=", ".join(a["name"] for a in entry.authors), + created_at=entry.created_at, + content=entry.content or "---") + for entry in entries + ] + + with NamedTemporaryFile(prefix=slugify(logbook.name), + suffix=".html", + delete=False) as f: + f.write('

{}

'.format(logbook.name).encode('utf8')) + f.write('
{}

'.format(logbook.description).encode('utf8')) + for entry_html in entries_html: + f.write(entry_html.encode('utf8')) + f.close() + return f.name \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index f59ed98..07f8d2b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,6 +14,7 @@ peewee==2.10.2 Pillow==5.0.0 pyldap==2.4.45 python-dateutil==2.6.1 +python-slugify==2.0.1 pytz==2017.3 six==1.11.0 uWSGI==2.0.15 diff --git a/frontend/src/app.css b/frontend/src/app.css index 5e498f8..58e4649 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -105,4 +105,17 @@ a { #entry .timestamp { display: block; } +} + +.vertical-text { + writing-mode: tb-rl; + margin: 0px; +} + +.showColumn{ + margin: 5px; +} +.hiddenColumn{ + background: #eee; + border-right: 1px solid #bbb; } \ No newline at end of file diff --git a/frontend/src/app.js b/frontend/src/app.js index 93c7223..597c610 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -58,7 +58,7 @@ class NoEntry extends React.Component { console.log(this.props.match.location); return (
- Select an entry to read it + Select an entry to read it {logbookId ? (
{" "} @@ -78,13 +78,61 @@ class NoEntry extends React.Component { } } -const Elogy = () => ( +class HiddenColumn extends React.Component { + + render() { + return ( +
+ + this.props.show()}/> + +

{this.props.text}

+
+ ); + } +} + + + +class Elogy extends React.Component { + + constructor() { + super(); + this.state = { + hideLogbookTree: false, + hideLogbook: false + }; + this._hideLogbookTree = this._hideLogbookTree.bind(this); + this._hideLogbook = this._hideLogbook.bind(this); + } + + componentDidMount() { + eventbus.subscribe("logbooktree.hide", this._hideLogbookTree); + eventbus.subscribe("logbook.hide", this._hideLogbook); + } + + componentWillUnmount() { + eventbus.unsubscribe("logbooktree.hide", this._hideLogbookTree); + eventbus.unsubscribe("logbook.hide", this._hideLogbook); + } + + _hideLogbookTree(hide) { + this.setState({hideLogbookTree: hide}); + } + + _hideLogbook(hide) { + this.setState({hideLogbook: hide}); + } + + render() { + + return ( /* Set up a browser router that will render the correct component in the right places, all depending on the current URL. */
-
+ {!this.state.hideLogbookTree ?
( /> -
+
: } -
+ {!this.state.hideLogbook ?
( /> -
+
: }
@@ -146,5 +194,6 @@ const Elogy = () => (
); - +} +} export default Elogy; diff --git a/frontend/src/logbook.css b/frontend/src/logbook.css index a56497d..082b483 100644 --- a/frontend/src/logbook.css +++ b/frontend/src/logbook.css @@ -21,6 +21,10 @@ border-bottom: 1px solid #aaa; } +#logbook header .commands { + float: right; +} + #logbook header .name { font-size: 120%; font-weight: bold; diff --git a/frontend/src/logbook.js b/frontend/src/logbook.js index e6cc261..1c666f8 100644 --- a/frontend/src/logbook.js +++ b/frontend/src/logbook.js @@ -188,6 +188,10 @@ class Logbook extends React.Component { this.props.history.push(`?sort_by=${sortBy}`); } + hide(){ + this.props.eventbus.publish("logbook.hide", true); + } + render() { const logbook = this.state.logbook, entryId = this.props.match.params.entryId @@ -239,7 +243,12 @@ class Logbook extends React.Component { {logbook.id === 0 ? "[All logbooks]" : logbook.name} - +
{logbook.id ? ( @@ -264,6 +273,17 @@ class Logbook extends React.Component { Configure {" "} |  + + Export HTML + {" "} + |  ) : null}
@@ -123,35 +113,43 @@ class LogbookEditorBase extends React.Component { this.setState({ description: event.target.value }); } - getAttributes(logbook) { - return this.state.attributes.map((attr, i) => ( -
- - {i} - - - - - - -
- )); + getAttributes() { + const { logbook, attributes } = this.state; + + return attributes.map((attr, i) => { + const existingAttribute = + logbook && logbook.attributes.some(({ name }) => name === attr.name); + + return ( +
+ + {i} + + + + + + +
+ ); + }); } findAttribute(name) { @@ -173,13 +171,24 @@ class LogbookEditorBase extends React.Component { } insertAttribute(index, event) { + const existingNames = this.state.attributes.map(({ name }) => name); + const nameBase = "New attribute"; + let attributeName = nameBase; + let counter = 1; + + while (existingNames.includes(attributeName)) { + attributeName = `${nameBase} (${counter})`; + counter++; + } + event.preventDefault(); const newAttribute = { type: "text", - name: "New attribute", + name: attributeName, options: [], required: false }; + this.setState( update(this.state, { attributes: { $splice: [[index, 0, newAttribute]] } @@ -483,7 +492,6 @@ class LogbookEditorEdit extends LogbookEditorBase { if (!this.state.id) return
Loading...
; - return (
@@ -491,8 +499,9 @@ class LogbookEditorEdit extends LogbookEditorBase {
Editing logbook "{this.state.logbook.name}" in
@@ -550,13 +559,22 @@ class LogbookEditorEdit extends LogbookEditorBase { Archived -
); } + + // Disable the submit button if there are duplicate field names + canSubmit() { + const attributeNames = this.state.attributes.map(({ name }) => name); + return !attributeNames.some((name, i) => attributeNames.indexOf(name) !== i); + } } class LogbookEditor extends React.Component { diff --git a/frontend/src/logbookselector.js b/frontend/src/logbookselector.js index 7e1d0c6..7e6116a 100644 --- a/frontend/src/logbookselector.js +++ b/frontend/src/logbookselector.js @@ -1,6 +1,5 @@ import React from "react"; - function flattenLogbook (logbook, ancestors) { // return a flat array of all the logbooks, along with their ancestors if (!logbook) @@ -14,7 +13,6 @@ function flattenLogbook (logbook, ancestors) { ); } - // build a nice path string out of the ancestors to the logbook const LogbookOption = ({ logbook, current, ancestors }) => { const logbookPath = (ancestors.join(" / ") @@ -28,7 +26,6 @@ const LogbookOption = ({ logbook, current, ancestors }) => { ); } - export class LogbookSelector extends React.Component { constructor () { @@ -52,19 +49,20 @@ export class LogbookSelector extends React.Component { onChange (event) { this.props.onLogbookChange(event.target.value); } - + render () { const options = flattenLogbook(this.state.logbook) - .filter(([logbook, _]) => logbook.id !== this.props.excludeId) + .filter(([logbook, ancestors]) => !ancestors.includes(this.props.currentName) && logbook.id !== this.props.currentId) .map(([logbook, ancestors]) => ( )); return ( - ); diff --git a/frontend/src/logbooktree.js b/frontend/src/logbooktree.js index ab2d696..6d5eb62 100644 --- a/frontend/src/logbooktree.js +++ b/frontend/src/logbooktree.js @@ -106,6 +106,10 @@ class LogbookTree extends React.Component { this.props.eventbus.unsubscribe("logbook.reload", this._reload); } + hide(){ + this.props.eventbus.publish("logbooktree.hide", true); + } + reload() { console.log("reload logbook tree"); this.fetch(this.props.location.search); @@ -148,6 +152,10 @@ class LogbookTree extends React.Component {
+ + this.hide()}/> + +   {logbookId !== this.state.id ? (