Commits

Michael P. Jung committed a130a3e

Add initial codebase

Comments (0)

Files changed (14)

+# binary (compiled) files
+*.o
+*.mo
+*.py[co]
+
+# other revision control systems
+CVS/
+.svn/
+
+# OSX specific junk
+.DS_Store
+
+# Windows specific junk
+[Dd]esktop.ini
+[Tt]humbs.db
+
+# editor files
+*.swp
+*.orig
+*~
+\#*
+.\#*
+
+# Old files and backup stuff
+*.old
+*.bak

dinbrief/__init__.py

Empty file added.

dinbrief/constants.py

+from reportlab.lib.pagesizes import A4
+from reportlab.lib.units import mm, cm
+
+
+PAGE_SIZE = A4
+PAGE_WIDTH = PAGE_SIZE[0]
+PAGE_HEIGHT = PAGE_SIZE[1]
+
+CONTENT_LEFT = 24.1*mm
+CONTENT_RIGHT = 8.1*mm
+CONTENT_WIDTH = PAGE_WIDTH - CONTENT_LEFT - CONTENT_RIGHT

dinbrief/document.py

+class Document(object):
+
+    title = ''
+    subject = ''
+    author = ''
+    keywords = None
+    creator = 'http://pypi.python.org/pypi/dinbrief'
+
+    sender = None
+    recipient = None
+    content = None
+    date = ''
+
+    def __init__(self, **kwargs):
+        # Metadata
+        self.title = kwargs.pop('title', self.title)
+        self.subject = kwargs.pop('subject', self.subject)
+        self.author = kwargs.pop('author', self.author)
+        self.keywords = kwargs.pop('keywords', self.keywords)
+        self.creator = kwargs.pop('creator', self.creator)
+        # Content
+        self.sender = kwargs.pop('sender', self.sender or [])
+        self.recipient = kwargs.pop('recipient', self.recipient or [])
+        self.date = kwargs.pop('date', self.date)
+        self.content = kwargs.pop('content', self.content or [])

dinbrief/invoice/__init__.py

+from .invoice import Invoice
+from .item import Item
+from .item_table import ItemTable
+from .total_table import TotalTable

dinbrief/invoice/invoice.py

+# -*- coding: utf-8 -*-
+
+from decimal import Decimal
+
+from .vat_item import VatItem
+
+
+class Invoice(object):
+
+    def __init__(self, items=None, currency=u'€'):
+        self.items = items or []
+        self.currency = currency
+        self.vat_items = []
+        self.recalculate()
+
+    def recalculate(self):
+        self.vat_items = []
+        d = {}
+        for item in self.items:
+            if not item.vat_rate:
+                continue
+            try:
+                vat_item = d[item.vat_rate]
+            except KeyError:
+                vat_item = VatItem(rate=item.vat_rate)
+                d[item.vat_rate] = vat_item
+            vat_item.amount += item.vat_rate * item.subtotal
+        self.vat_items = d.values()
+        self.vat_items.sort(key=lambda item: item.rate)
+
+    @property
+    def gross(self):
+        return self.net + sum(vat_item.amount for vat_item in self.vat_items)
+
+    @property
+    def net(self):
+        return sum(item.total for item in self.items)

dinbrief/invoice/item.py

+from decimal import Decimal
+
+
+class Item(object):
+
+    def __init__(self, position=0, name='', period='', price=Decimal(0),
+            unit='', quantity=Decimal(1), discount=Decimal(0),
+            vat_rate=Decimal(0)):
+        self.position = position
+        self.name = name
+        self.period = period
+        self.price = price
+        self.unit = unit
+        self.quantity = quantity
+        self.discount = discount
+        self.vat_rate = vat_rate
+
+    def get_unit_display(self):
+        return self.unit
+
+    @property
+    def subtotal(self):
+        return self.price * self.quantity
+
+    @property
+    def discount_percentage(self):
+        return self.discount * 100
+
+    @property
+    def discount_amount(self):
+        return self.discount * self.subtotal
+
+    @property
+    def total(self):
+        return self.subtotal - self.discount_amount

dinbrief/invoice/item_table.py

+# -*- coding: utf-8 -*-
+
+from decimal import Decimal
+from functools import partial
+from xml.sax.saxutils import escape
+
+from reportlab.lib import colors
+from reportlab.lib.units import mm, cm
+from reportlab.platypus import Paragraph
+from reportlab.platypus.tables import Table
+from reportlab.platypus.tables import TableStyle
+
+from ..constants import CONTENT_WIDTH
+from ..optional_django import ugettext as _
+from ..optional_django import number_format
+from ..styles import styles
+
+
+Head = partial(Paragraph, style=styles['TableHead'])
+HeadRight = partial(Paragraph, style=styles['TableHeadRight'])
+Cell = partial(Paragraph, style=styles['TableCell'])
+Number = partial(Paragraph, style=styles['TableNumber'])
+
+
+def ItemTable(invoice):
+
+    style = [
+        ('VALIGN', (0, 0), (-1,  0), 'BOTTOM'),
+        ('VALIGN', (0, 1), (-1, -1), 'TOP'),
+        ('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.8, 0.8, 0.8)),
+        ('LINEBELOW', (0, 0), (-1, 0), 0.3*mm, colors.black),
+        #('LINEBELOW', (0, 1), (-1, -1), 0.1*mm, colors.black),
+        ('TOPPADDING', (0, 0), (-1, -1), 2*mm),
+        ('RIGHTPADDING', (0, 0), (-1, -1), 2*mm),
+        ('BOTTOMPADDING', (0, 0), (-1, -1), 2*mm),
+        ('LEFTPADDING', (0, 0), (-1, -1), 2*mm),
+        # no padding between net price and unit
+        ('RIGHTPADDING', (3, 0), (3, -1), 0),
+        ('LEFTPADDING', (4, 0), (4, -1), 0),
+    ]
+
+    show_period_column = any(item.period for item in invoice.items)
+
+    col_widths = [
+            8*mm, # position
+            0,    # description
+            46*mm, # period
+            24*mm, # net price
+            14*mm, # unit
+            14*mm, # quantity
+            24*mm  # line total
+    ]
+    col_widths[1] = CONTENT_WIDTH - sum(col_widths)
+
+    def data_generator():
+        # header
+        yield (
+            HeadRight(u'#'),
+            Head(_('Description')),
+            Head(_('Period') if show_period_column else ''),
+            HeadRight(_('Net Price')),
+            Head(u''),
+            HeadRight(_('Quantity')),
+            HeadRight(_('Line Total')),
+        )
+        # items
+        row = 0
+        for item in invoice.items:
+            row += 1
+            if not item.period:
+                style.append(('SPAN', (1, row), (2, row)))
+            yield (
+                Number(unicode(item.position)),
+                Cell(escape(item.name)),
+                Cell(escape(item.period)),
+                Number(u'%s €' % number_format(item.price, 2)),
+                Cell((u'/%s' % escape(item.get_unit_display()))
+                    if item.unit else u''),
+                Number(number_format(item.quantity, 2)),
+                Number(u'%s €' % number_format(item.subtotal, 2)),
+            )
+            if item.discount:
+                row += 1
+                percentage = number_format(item.discount_percentage)
+                yield (
+                    Cell(u''),
+                    Cell((u'%s%% ' % percentage) + _('discount')),
+                    Cell(u''),
+                    Cell(u''),
+                    Cell(u''),
+                    Cell(u''),
+                    Number(u'–%s €' % number_format(item.discount_amount, 2)),
+                )
+                style.append(('TOPPADDING', (0, row), (-1, row), 0))
+            # draw line below item
+            style.append(('LINEBELOW', (0, row), (-1, row),
+                0.1*mm, colors.black))
+
+    return Table(
+        data=list(data_generator()),
+        colWidths=col_widths,
+        style=TableStyle(style),
+        repeatRows=1)

dinbrief/invoice/total_table.py

+# -*- coding: utf-8 -*-
+
+from decimal import Decimal
+from xml.sax.saxutils import escape
+
+from reportlab.lib import colors
+from reportlab.lib.units import mm, cm
+from reportlab.platypus import Paragraph
+from reportlab.platypus import Spacer
+from reportlab.platypus.tables import Table
+from reportlab.platypus.tables import TableStyle
+
+from ..constants import CONTENT_WIDTH
+from ..optional_django import ugettext as _
+from ..optional_django import number_format
+from ..styles import styles
+
+
+def TotalTable(invoice):
+
+    table_style = [
+        ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+        ('TOPPADDING', (0, 0), (-1, -1), 1*mm),
+        ('RIGHTPADDING', (0, 0), (-1, -1), 2*mm),
+        ('BOTTOMPADDING', (0, 0), (-1, -1), 1*mm),
+        ('LEFTPADDING', (0, 0), (-1, -1), 2*mm),
+    ]
+
+    col_widths = [0, 32*mm, 24*mm]
+    col_widths[0] = CONTENT_WIDTH - sum(col_widths)
+
+    if invoice.vat_items:
+        net_row = 0
+        vat_row = net_row+1
+        gross_row = vat_row + len(invoice.vat_items)
+        table_style += [
+            ('BOTTOMPADDING', (0, gross_row-1), (-1, gross_row-1), 2*mm),
+            #('TOPPADDING', (0, vat_row), (-1, gross_row), 1*mm),
+            #('BOTTOMPADDING', (0, vat_row), (-1, gross_row), 1*mm),
+            ('LINEABOVE', (0, net_row), (-1, net_row), 0.3*mm, colors.black),
+            ('LINEABOVE', (1, gross_row), (-1, gross_row), 0.5*mm, colors.black),
+        ]
+        def data_generator():
+            yield (
+                Paragraph(u'', styles['TableCell']),
+                Paragraph(_(u'Sum (net)'), styles['TableCell']),
+                Paragraph(u'%s €' % number_format(invoice.net, 2),
+                    styles['TableNumber']),
+            )
+            for vat_item in invoice.vat_items:
+                yield (
+                    Paragraph(u'', styles['TableCell']),
+                    Paragraph((u'+%s%% ' % number_format(vat_item.rate * 100)) +
+                        _('VAT'), styles['TableCell']),
+                    Paragraph(u'%s €' % number_format(vat_item.amount, 2),
+                        styles['TableNumber']),
+                )
+            yield (
+                Paragraph(u'', styles['TableCell']),
+                Paragraph(_(u'Sum (gross)'), styles['GrossTableCell']),
+                Paragraph(u'%s €' % number_format(invoice.gross, 2),
+                    styles['GrossValueTableCell']),
+            )
+    else:
+        net_row = 0
+        gross_row = 1
+        table_style += [
+            ('TOPPADDING', (0, net_row), (-1, net_row), 2*mm),
+            ('BOTTOMPADDING', (0, net_row), (-1, net_row), 0),
+            ('LINEABOVE', (0, net_row), (-1, net_row), 0.3*mm, colors.black),
+            ('TOPPADDING', (0, gross_row), (-1, gross_row), 2*mm),
+            ('LINEABOVE', (1, gross_row), (-1, gross_row), 0.5*mm, colors.black),
+        ]
+        def data_generator():
+            yield (
+                Spacer(0, 0),
+                Spacer(0, 0),
+                Spacer(0, 0),
+            )
+            yield (
+                Paragraph(u'', styles['TableCell']),
+                Paragraph(_(u'Sum (net)'), styles['GrossTableCell']),
+                Paragraph(u'%s €' % number_format(invoice.gross, 2),
+                    styles['GrossValueTableCell']),
+            )
+
+    return Table(
+        data=list(data_generator()),
+        colWidths=col_widths,
+        style=TableStyle(table_style))

dinbrief/invoice/vat_item.py

+from decimal import Decimal
+
+
+class VatItem(object):
+    def __init__(self, rate, amount=Decimal(0)):
+        self.rate = rate
+        self.amount = amount

dinbrief/optional_django.py

+# Use functions provided by django but do not depend on it.
+
+try:
+    from django.utils.translation import gettext, ugettext
+except:
+    from gettext import gettext
+    ugettext = gettext
+
+try:
+    from django.utils.formats import number_format
+except:
+    def number_format(value, decimal_places=''):
+        format = '%%.%sf' % decimal_places
+        return format % value

dinbrief/styles.py

+from reportlab.lib import colors
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.styles import StyleSheet1
+from reportlab.lib.units import mm, cm
+from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
+
+
+styles = StyleSheet1()
+
+styles.add(ParagraphStyle(
+    name='Normal',
+    fontName='Helvetica',
+    fontSize=10,
+    leading=12
+))
+
+styles.add(ParagraphStyle(
+    name='InfoboxTitle',
+    parent=styles['Normal'],
+    fontSize=7,
+    leading=8,
+    spaceAfter=0,
+    textColor=colors.white))
+
+styles.add(ParagraphStyle(
+    name='InfoboxText',
+    parent=styles['Normal'],
+    fontSize=10,
+    leading=10,
+    spaceAfter=1.5*mm,
+    textColor=colors.white))
+
+styles.add(ParagraphStyle(
+    name='Footer',
+    fontSize=8,
+    leading=9,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='FooterTitle',
+    fontSize=7,
+    leading=10,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='FooterText',
+    fontSize=9,
+    leading=10,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='Sender',
+    fontSize=8,
+    leading=8,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='Recipient',
+    fontSize=12,
+    leading=14,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='Text',
+    spaceBefore=2*mm,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='Subject',
+    fontSize=12,
+    leading=12,
+    spaceAfter=8*mm,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='Date',
+    alignment=TA_RIGHT,
+    parent=styles['Subject']))
+
+styles.add(ParagraphStyle(
+    name='Greeting',
+    spaceBefore=2*mm,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='Message',
+    spaceBefore=2*mm,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='TableCell',
+    fontSize=10,
+    leading=10,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='TableHead',
+    parent=styles['TableCell']))
+
+styles.add(ParagraphStyle(
+    name='TableHeadRight',
+    alignment=TA_RIGHT,
+    parent=styles['TableHead']))
+
+styles.add(ParagraphStyle(
+    name='TableNumber',
+    alignment=TA_RIGHT,
+    parent=styles['TableCell']))
+
+styles.add(ParagraphStyle(
+    name='GrossTableCell',
+    fontName='Helvetica-Bold',
+    parent=styles['TableCell']))
+
+styles.add(ParagraphStyle(
+    name='GrossValueTableCell',
+    alignment=TA_RIGHT,
+    parent=styles['GrossTableCell']))
+
+styles.add(ParagraphStyle(
+    name='Terms',
+    spaceAfter=2*mm,
+    parent=styles['Normal']))
+
+styles.add(ParagraphStyle(
+    name='Closing',
+    spaceBefore=8*mm,
+    parent=styles['Text']))
+
+styles.add(ParagraphStyle(
+    name='Signature',
+    parent=styles['Text']))

dinbrief/template.py

+# -*- coding: utf-8 -*-
+
+from xml.sax.saxutils import escape
+
+from reportlab import platypus
+from reportlab.lib import colors
+from reportlab.lib.units import mm, cm
+from reportlab.platypus import Frame
+from reportlab.platypus import PageTemplate
+from reportlab.platypus import Paragraph
+from reportlab.platypus import KeepInFrame
+from reportlab.platypus import Table
+from reportlab.platypus import TableStyle
+
+from .constants import PAGE_SIZE, PAGE_WIDTH, PAGE_HEIGHT
+from .constants import CONTENT_LEFT, CONTENT_WIDTH
+from .styles import styles
+
+
+# Address according to DIN 676 und DIN 5008
+ADDRESS_WIDTH = 85*mm
+ADDRESS_HEIGHT = 55*mm
+ADDRESS_X = CONTENT_LEFT # correct would be 20mm, but this looks very strange
+ADDRESS_Y = PAGE_HEIGHT - ADDRESS_HEIGHT - 45*mm - 3*mm
+
+SENDER_X = ADDRESS_X
+SENDER_Y = ADDRESS_Y + ADDRESS_HEIGHT - 13*mm
+SENDER_HEIGHT = 10*mm
+SENDER_WIDTH = ADDRESS_WIDTH
+
+SENDER_LINE_Y = PAGE_HEIGHT - 55*mm
+SENDER_LINE_COLOR = colors.black
+SENDER_LINE_WIDTH = 0.1*mm
+
+RECIPIENT_X = ADDRESS_X
+RECIPIENT_Y = ADDRESS_Y
+RECIPIENT_HEIGHT = ADDRESS_HEIGHT - 10*mm
+RECIPIENT_WIDTH = PAGE_WIDTH - ADDRESS_X
+
+DATE_Y = 45*mm
+DATE_HEIGHT = PAGE_HEIGHT-140*mm
+
+
+class BasePageTemplate(PageTemplate, object):
+
+    def __init__(self, document, *args, **kwargs):
+        self.document = document
+        super(BasePageTemplate, self).__init__(
+            *args, **kwargs)
+
+    def afterDrawPage(self, canvas, document):
+        self.draw_header(canvas)
+        self.draw_footer(canvas)
+        self.draw_marks(canvas)
+
+    def draw_header(self, canvas):
+        pass
+
+    def draw_marks(self, canvas):
+        canvas.saveState()
+        canvas.setLineWidth(0.1*mm)
+
+        # upper fold mark
+        p = canvas.beginPath()
+        p.moveTo(4*mm, PAGE_HEIGHT-105*mm)
+        p.lineTo(8*mm, PAGE_HEIGHT-105*mm)
+        canvas.drawPath(p)
+
+        # lower fold mark
+        p = canvas.beginPath()
+        p.moveTo(4*mm, PAGE_HEIGHT-210*mm)
+        p.lineTo(8*mm, PAGE_HEIGHT-210*mm)
+        canvas.drawPath(p)
+
+        # center mark
+        p = canvas.beginPath()
+        p.moveTo(6*mm, PAGE_HEIGHT / 2)
+        p.lineTo(13*mm, PAGE_HEIGHT / 2)
+        canvas.drawPath(p)
+
+        canvas.restoreState()
+
+    def draw_footer(self, canvas):
+        pass
+
+    
+class FirstPageTemplate(BasePageTemplate):
+
+    def __init__(self, document):
+        super(FirstPageTemplate, self).__init__(
+            document=document,
+            id='First', frames=[
+                # left=25mm, right=10mm, top=115mm, bottom=15mm
+                Frame(
+                    x1=CONTENT_LEFT, y1=45*mm,
+                    width=CONTENT_WIDTH, height=PAGE_HEIGHT-140*mm,
+                    leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0)
+            ])
+
+    def draw_address(self, canvas):
+
+        '''
+        canvas.saveState()
+
+        # sender line
+        canvas.setStrokeColor(SENDER_LINE_COLOR)
+        canvas.setLineWidth(SENDER_LINE_WIDTH)
+        p = canvas.beginPath()
+        p.moveTo(0, SENDER_LINE_Y)
+        p.lineTo(PAGE_WIDTH, SENDER_LINE_Y)
+        canvas.drawPath(p)
+
+        canvas.restoreState()
+        '''
+
+        # sender text
+        sender = Frame(
+                SENDER_X, SENDER_Y,
+                SENDER_WIDTH, SENDER_HEIGHT,
+                0, 0, 0, 0)
+        sender.add(
+                Paragraph(
+                    u' · '.join(map(escape, self.document.sender)),
+                    styles['Sender']),
+                canvas)
+
+        # recipient text
+        recipient = Frame(
+                RECIPIENT_X, RECIPIENT_Y,
+                RECIPIENT_WIDTH, RECIPIENT_HEIGHT,
+                0, 0, 0, 0)
+        recipient.add(
+                Paragraph(
+                    u'<br/>'.join(map(escape, self.document.recipient)),
+                    styles['Recipient']),
+                canvas)
+
+    def draw_date(self, canvas):
+        frame = Frame(
+                CONTENT_LEFT, DATE_Y,
+                CONTENT_WIDTH, DATE_HEIGHT,
+                0, 0, 0, 0)
+        frame.add(
+                Paragraph(
+                    escape(self.document.date),
+                    styles['Date']),
+                canvas)
+
+
+    def afterDrawPage(self, canvas, document):
+        BasePageTemplate.afterDrawPage(self, canvas, document)
+        self.draw_address(canvas)
+        self.draw_date(canvas)
+
+
+class LaterPageTemplate(BasePageTemplate):
+
+    def __init__(self, document):
+        super(LaterPageTemplate, self).__init__(
+                document=document,
+                id='Later', frames=[
+                    # left=20mm, right=10mm, top=20mm, bottom=15mm
+                    Frame(25*mm, 40*mm, PAGE_WIDTH-35*mm, PAGE_HEIGHT-90*mm)
+                ])
+
+
+class BriefTemplate(platypus.BaseDocTemplate):
+
+    def __init__(self, fh, document):
+        # super can not be used as BaseDocTemplate is an old style class.
+        platypus.BaseDocTemplate.__init__(self,
+            fh,
+            pagesize=PAGE_SIZE,
+            pageTemplates=[
+                FirstPageTemplate(document),
+                LaterPageTemplate(document)
+            ],
+            title=document.title,
+            subject=document.subject,
+            author=document.author,
+            keywords=document.keywords,
+            creator=document.creator
+        )
+    
+    def handle_pageBegin(self):
+        self._handle_pageBegin()
+        self._handle_nextPageTemplate('Later')
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+from decimal import Decimal
+
+import dinbrief.template
+
+from reportlab.lib.units import mm
+from reportlab.platypus import Paragraph
+from reportlab.platypus.flowables import KeepTogether
+from reportlab.platypus.flowables import Spacer
+
+from dinbrief.constants import CONTENT_WIDTH
+from dinbrief.document import Document
+from dinbrief.invoice import Invoice, Item, ItemTable, TotalTable
+from dinbrief.styles import styles
+from dinbrief.template import BriefTemplate
+
+
+FOOD_VAT = Decimal('0.07')
+DEFAULT_VAT = Decimal('0.19')
+
+with file('test.pdf', 'w') as fh:
+    invoice = Invoice(
+        items=[
+            Item(1, u'Donut', price=Decimal('1.00'), vat_rate=FOOD_VAT, quantity=100),
+            Item(2, u'Brezel', price=Decimal('0.50'), vat_rate=FOOD_VAT, quantity=200, discount=Decimal('0.25')),
+            Item(3, u'Backautomat miete', price=Decimal('50'), vat_rate=DEFAULT_VAT, quantity=4, unit='Tag', period=u'04.08.2012 - 07.10.2012'),
+            Item(4, u'Versicherungspauschale: Personenschäden bis 100.000 EUR, Sachschäden bis 50.000 EUR.', price=Decimal('30'), vat_rate=DEFAULT_VAT),
+        ])
+    document = Document(
+        sender=[
+            u'Musterfirma',
+            u'Finkengasse 1',
+            u'00000 Musterort'
+        ],
+        recipient=[
+            u'Max Mustermann',
+            u'Lärchenweg 22',
+            u'00000 Musterort'
+        ],
+        date='1.1.1970',
+        content=[
+            Paragraph(u'Rechnung 2012-0815', styles['Subject']),
+            #Paragraph(u'Sehr geehrter Herr Mustermann,', styles['Greeting']),
+            #Paragraph(u'Hiermit möchten wir Ihnen nachfolgende Posten in Rechnung stellen:', styles['Message']),
+            Spacer(CONTENT_WIDTH, 2*mm),
+            ItemTable(invoice),
+            TotalTable(invoice),
+        ])
+    template = BriefTemplate(fh, document)
+    template.build(document.content)