diff --git a/config b/config index 7b5702a..abf433a 100644 --- a/config +++ b/config @@ -89,11 +89,18 @@ file = ~/.config/radicale/rights [storage] # Storage backend +# Value: filesystem | database type = filesystem # Folder for storing local collections, created if not present filesystem_folder = ~/.config/radicale/collections +# Database URL for SQLAlchemy +# dialect+driver://user:password@host/dbname[?key=value..] +# For example: sqlite:///var/db/radicale.db, postgresql://user:password@localhost/radicale +# See http://docs.sqlalchemy.org/en/rel_0_8/core/engines.html#sqlalchemy.create_engine +database_url = + [logging] # Logging configuration file diff --git a/radicale/config.py b/radicale/config.py index e07132e..3967480 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -73,8 +73,7 @@ INITIAL_CONFIG = { "type": "filesystem", "filesystem_folder": os.path.expanduser( "~/.config/radicale/collections"), - "git_folder": os.path.expanduser( - "~/.config/radicale/collections")}, + "database_url": ""}, "logging": { "config": "/etc/radicale/logging", "debug": "False", diff --git a/radicale/storage/database.py b/radicale/storage/database.py new file mode 100644 index 0000000..02ef171 --- /dev/null +++ b/radicale/storage/database.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Radicale Server - Calendar Server +# Copyright © 2013 Guillaume Ayoub +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +SQLAlchemy storage backend. + +""" + +import time +from datetime import datetime +from contextlib import contextmanager +from sqlalchemy import create_engine, Column, String, DateTime, ForeignKey +from sqlalchemy import func +from sqlalchemy.orm import sessionmaker, relationship +from sqlalchemy.ext.declarative import declarative_base + +from .. import config, ical + + +Base = declarative_base() +Session = sessionmaker() +Session.configure(bind=create_engine(config.get("storage", "database_url"))) + + +class DBCollection(Base): + __tablename__ = "collection" + + path = Column(String, primary_key=True) + parent_path = Column(String, ForeignKey("collection.path")) + + parent = relationship("DBCollection", backref="children", remote_side=[path]) + + +class DBItem(Base): + __tablename__ = "item" + + name = Column(String, primary_key=True) + tag = Column(String) + collection_path = Column(String, ForeignKey("collection.path")) + + collection = relationship("DBCollection", backref="items") + + +class DBHeader(Base): + __tablename__ = "header" + + key = Column(String, primary_key=True) + value = Column(String) + collection_path = Column(String, ForeignKey("collection.path"), primary_key=True) + + collection = relationship("DBCollection", backref="headers") + + +class DBLine(Base): + __tablename__ = "line" + + key = Column(String, primary_key=True) + value = Column(String) + item_name = Column(String, ForeignKey("item.name"), primary_key=True) + timestamp = Column(DateTime, default=datetime.now) + + item = relationship( + "DBItem", backref="lines", order_by=timestamp) + + +class DBProperty(Base): + __tablename__ = "property" + + key = Column(String, primary_key=True) + value = Column(String) + collection_path = Column(String, ForeignKey("collection.path"), primary_key=True) + + collection = relationship( + "DBCollection", backref="properties", cascade="delete") + + +class Collection(ical.Collection): + """Collection stored in a database.""" + def __init__(self, path, principal=False): + self.session = Session() + super(Collection, self).__init__(path, principal) + + def __del__(self): + self.session.commit() + #super(Collection, self).__del__() + + def _query(self, item_types): + item_objects = [] + for item_type in item_types: + items = ( + self.session.query(DBItem) + .filter_by(collection_path=self.path, tag=item_type.tag) + .order_by("name").all()) + for item in items: + text = "\n".join( + "%s:%s" % (line.key, line.value) for line in item.lines) + item_objects.append(item_type(text, item.name)) + return item_objects + + @property + def _modification_time(self): + return ( + self.session.query(func.max(DBLine.timestamp)) + .join(DBItem).filter_by(collection_path=self.path).first())[0] + + @property + def _db_collection(self): + return self.session.query(DBCollection).get(self.path) + + def write(self, headers=None, items=None): + headers = headers or self.headers or ( + ical.Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"), + ical.Header("VERSION:%s" % self.version)) + items = items if items is not None else self.items + + if self._db_collection: + for item in self._db_collection.items: + for line in item.lines: + self.session.delete(line) + self.session.delete(item) + for header in self._db_collection.headers: + self.session.delete(header) + else: + db_collection = DBCollection() + db_collection.path = self.path + self.session.add(db_collection) + + for header in headers: + db_header = DBHeader() + db_header.key, db_header.value = header.text.split(":", 1) + db_header.collection_path = self.path + self.session.add(db_header) + + for item in items: + db_item = DBItem() + db_item.name = item.name + db_item.tag = item.tag + db_item.collection_path = self.path + self.session.add(db_item) + + for line in ical.unfold(item.text): + db_line = DBLine() + db_line.key, db_line.value = line.split(":", 1) + db_line.item_name = item.name + self.session.add(db_line) + + def delete(self): + self.session.delete(self._db_collection) + + @property + def text(self): + return ical.serialize(self.tag, self.headers, self.items) + + @property + def etag(self): + return '"%s"' % hash(self._modification_time) + + @property + def headers(self): + headers = ( + self.session.query(DBHeader) + .filter_by(collection_path=self.path) + .order_by("key").all()) + return [ + ical.Header("%s:%s" % (header.key, header.value)) + for header in headers] + + @classmethod + def children(cls, path): + session = Session() + if path: + children = session.query(DBCollection).get(path).children + else: + children = session.query(DBCollection).filter_by(parent=None).all() + collections = [cls(child.path) for child in children] + session.close() + return collections + + @classmethod + def is_node(cls, path): + if not path: + return True + session = Session() + result = ( + session.query(DBCollection) + .filter_by(parent_path=path).count() > 0) + session.close() + return result + + @classmethod + def is_leaf(cls, path): + if not path: + return False + session = Session() + result = ( + session.query(DBItem) + .filter_by(collection_path=path).count() > 0) + session.close() + return result + + @property + def last_modified(self): + return time.strftime( + "%a, %d %b %Y %H:%M:%S +0000", self._modification_time.timetuple()) + + @property + @contextmanager + def props(self): + # On enter + properties = {} + db_properties = ( + self.session.query(DBProperty) + .filter_by(collection_path=self.path).all()) + for prop in db_properties: + properties[prop.key] = prop.value + old_properties = properties.copy() + yield properties + # On exit + if self._db_collection and old_properties != properties: + for prop in db_properties: + self.session.delete(prop) + for key, value in properties.items(): + prop = DBProperty() + prop.key = key + prop.value = value + prop.collection_path = self.path + self.session.add(prop) + + @property + def items(self): + return self._query( + (ical.Event, ical.Todo, ical.Journal, ical.Card, ical.Timezone)) + + @property + def components(self): + return self._query((ical.Event, ical.Todo, ical.Journal, ical.Card)) + + @property + def events(self): + return self._query((ical.Event,)) + + @property + def todos(self): + return self._query((ical.Todo,)) + + @property + def journals(self): + return self._query((ical.Journal,)) + + @property + def timezones(self): + return self._query((ical.Timezone,)) + + @property + def cards(self): + return self._query((ical.Card,))