From 2727bbb9a2c91a97742d4757247a30e31742e33e Mon Sep 17 00:00:00 2001 From: Santiago Collazo Date: Wed, 17 Oct 2012 18:08:47 +0000 Subject: Merge remote-tracking branch 'ajay/0.97.7-as-base' into integration --- diff --git a/bin/Makefile.am b/bin/Makefile.am index 845816c..df53e04 100644 --- a/bin/Makefile.am +++ b/bin/Makefile.am @@ -3,7 +3,9 @@ python_scripts = \ sugar-emulator \ sugar-install-bundle \ sugar-launch \ - sugar-session + sugar-session \ + journal-backup-volume \ + journal-restore-volume bin_SCRIPTS = \ sugar \ diff --git a/bin/journal-backup-volume b/bin/journal-backup-volume new file mode 100644 index 0000000..9246760 --- /dev/null +++ b/bin/journal-backup-volume @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# Copyright (C) 2010, Paraguay Educa +# +# This program 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 program 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 this program. If not, see . +# + +import os +import sys +import subprocess +import logging + +from sugar import env +#from sugar.datastore import datastore + +backup_identifier = sys.argv[2] +volume_path = sys.argv[1] + +if len(sys.argv) != 3: + print 'Usage: %s ' % sys.argv[0] + exit(1) + +logging.debug('Backup started') + +backup_path = os.path.join(volume_path, 'backup', backup_identifier) + +if not os.path.exists(backup_path): + os.makedirs(backup_path) + +#datastore.freeze() +#subprocess.call(['pkill', '-9', '-f', 'python.*datastore-service']) + +result = 0 +try: + cmd = ['tar', '-C', env.get_profile_path(), '-czf', \ + os.path.join(backup_path, 'datastore.tar.gz'), 'datastore'] + + subprocess.check_call(cmd) + +except Exception, e: + logging.error('Backup failed: %s', str(e)) + result = 1 + +#datastore.thaw() + +logging.debug('Backup finished') +exit(result) diff --git a/bin/journal-restore-volume b/bin/journal-restore-volume new file mode 100644 index 0000000..f3ad6d8 --- /dev/null +++ b/bin/journal-restore-volume @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# Copyright (C) 2010, Paraguay Educa +# +# This program 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 program 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 this program. If not, see . +# + +import os +import sys +import shutil +import logging +import subprocess + +from sugar import env +#from sugar.datastore import datastore + +backup_identifier = sys.argv[2] +volume_path = sys.argv[1] + +if len(sys.argv) != 3: + print 'Usage: %s ' % sys.argv[0] + exit(1) + +logging.debug('Restore started') + +journal_path = os.path.join(env.get_profile_path(), 'datastore') +backup_path = os.path.join(volume_path, 'backup', backup_identifier, 'datastore.tar.gz') + +if not os.path.exists(backup_path): + logging.error('Could not find backup file %s', backup_path) + exit(1) + +#datastore.freeze() +subprocess.call(['pkill', '-9', '-f', 'python.*datastore-service']) + +result = 0 +try: + if os.path.exists(journal_path): + shutil.rmtree(journal_path) + + subprocess.check_call(['tar', '-C', env.get_profile_path(), '-xzf', backup_path]) + +except Exception, e: + logging.error('Restore failed: %s', str(e)) + result = 1 + +try: + shutil.rmtree(os.path.join(journal_path, 'index')) + os.remove(os.path.join(journal_path, 'index_updated')) + os.remove(os.path.join(journal_path, 'version')) +except: + logging.debug('Restore has no index files') + +#datastore.thaw() + +logging.debug('Restore finished') +exit(result) diff --git a/bin/sugar-launch b/bin/sugar-launch index 7297a8e..18c0bb7 100644 --- a/bin/sugar-launch +++ b/bin/sugar-launch @@ -28,6 +28,13 @@ usage = "usage: %prog [options] activity" parser = OptionParser(usage) parser.add_option("-d", "--debug", action="store_true", dest="debug", help="launch activity inside gdb") +parser.add_option("-a", "--activity_id", action="store", dest="activity_id", + help="unique id for the activity to be launched") +parser.add_option("-o", "--object_id", action="store", dest="object_id", + help="identity of the journal object associated with" \ + " the activity") +parser.add_option("-u", "--uri", action="store", dest="uri", + help="URI associated with the activity") (options, args) = parser.parse_args() if len(args) == 0: @@ -42,7 +49,8 @@ if not path: sys.exit(1) activity = ActivityBundle(path) -cmd_args = activityfactory.get_command(activity) +cmd_args = activityfactory.get_command(activity, options.activity_id, + options.object_id, options.uri) def _which(exec_file): if 'PATH' in os.environ: diff --git a/bin/sugar-session b/bin/sugar-session index 5cdc028..8475b9b 100755 --- a/bin/sugar-session +++ b/bin/sugar-session @@ -22,6 +22,7 @@ import time import subprocess import shutil + # Change the default encoding to avoid UnicodeDecodeError # http://lists.sugarlabs.org/archive/sugar-devel/2012-August/038928.html reload(sys) @@ -43,6 +44,10 @@ from gi.repository import GObject from gi.repository import Gst import dbus.glib from gi.repository import Wnck +from gi.repository import Gio + +MONITORS = [] +MONITOR_ACTION_TAKEN = False _USE_XKL = False try: @@ -143,6 +148,15 @@ def setup_notification_service_cb(): from jarabe.model import notifications notifications.init() +def show_notifications_cb(): + client = GConf.Client.get_default() + if not client.get_bool('/desktop/sugar/frame/show_notifications'): + return + + from ceibal.notifier import Notifier + n = Notifier() + n.show_messages_from_shell() + def setup_file_transfer_cb(): from jarabe.model import filetransfer filetransfer.init() @@ -218,14 +232,31 @@ def setup_window_manager(): shell=True): logging.warning('Can not disable metacity mouse button modifiers') +def file_monitor_changed_cb(monitor, one_file, other_file, event_type): + global MONITOR_ACTION_TAKEN + if (not MONITOR_ACTION_TAKEN) and \ + (one_file.get_path() == os.path.expanduser('~/.sugar/journal_created')): + if event_type == Gio.FileMonitorEvent.CREATED: + GObject.idle_add(show_notifications_cb) + GObject.idle_add(setup_frame_cb) + MONITOR_ACTION_TAKEN = True + +def arrange_for_setup_frame_cb(): + path = Gio.File.new_for_path(os.path.expanduser('~/.sugar/journal_created')) + monitor = path.monitor_file(Gio.FileMonitorFlags.NONE, None) + monitor.connect('changed', file_monitor_changed_cb) + MONITORS.append(monitor) + def bootstrap(): setup_window_manager() from jarabe.view import launcher launcher.setup() - GObject.idle_add(setup_frame_cb) GObject.idle_add(setup_keyhandler_cb) + + arrange_for_setup_frame_cb() + GObject.idle_add(setup_gesturehandler_cb) GObject.idle_add(setup_cursortracker_cb) GObject.idle_add(setup_journal_cb) diff --git a/bin/sugar.in b/bin/sugar.in index 2df0ab8..428cbf2 100644 --- a/bin/sugar.in +++ b/bin/sugar.in @@ -42,7 +42,7 @@ if test -z "$SUGAR_PROFILE"; then fi if test -z "$SUGAR_SCALING"; then - export SUGAR_SCALING=72 + export SUGAR_SCALING=100 fi export GTK2_RC_FILES="@prefix@/share/sugar/data/sugar-$SUGAR_SCALING.gtkrc" diff --git a/configure.ac b/configure.ac index 4d28e4a..2052586 100644 --- a/configure.ac +++ b/configure.ac @@ -77,6 +77,8 @@ src/jarabe/model/Makefile src/jarabe/util/Makefile src/jarabe/util/telepathy/Makefile src/jarabe/view/Makefile +src/webdav/Makefile +src/webdav/acp/Makefile src/Makefile ]) diff --git a/extensions/cpsection/aboutcomputer/model.py b/extensions/cpsection/aboutcomputer/model.py index 86d2e3f..5d327ea 100644 --- a/extensions/cpsection/aboutcomputer/model.py +++ b/extensions/cpsection/aboutcomputer/model.py @@ -1,4 +1,5 @@ # Copyright (C) 2008 One Laptop Per Child +# Copyright (C) 2010 Plan Ceibal # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,9 +19,12 @@ import os import logging import re +import ConfigParser +import time import subprocess from gettext import gettext as _ import errno +from datetime import datetime import dbus @@ -39,6 +43,9 @@ _DMI_DIRECTORY = '/sys/class/dmi/id' _SN = 'serial-number' _MODEL = 'openprom/model' +_XO_1_0_LEASE_PATH = '/security/lease.sig' +_XO_1_5_LEASE_PATH = '/bootpart/boot/security/lease.sig' + _logger = logging.getLogger('ControlPanel - AboutComputer') _not_available = _('Not available') @@ -53,6 +60,31 @@ def print_aboutcomputer(): print get_aboutcomputer() +def _get_lease_path(): + if os.path.exists(_XO_1_0_LEASE_PATH): + return _XO_1_0_LEASE_PATH + elif os.path.exists(_XO_1_5_LEASE_PATH): + return _XO_1_5_LEASE_PATH + else: + return '' + + +def get_lease_days(): + lease_file = _read_file(_get_lease_path()) + if lease_file is None: + return _not_available + + encoded_date = str(str.split(lease_file)[3]) + expiry_date = datetime.strptime(encoded_date + , '%Y%m%dT%H%M%SZ') + current_date = datetime.today() + days_remaining = (expiry_date - current_date).days + + # TRANS: Do not translate %s + str_days_remaining = _('%s days remaining' % str(days_remaining)) + return str_days_remaining + + def get_serial_number(): serial_no = None if os.path.exists(os.path.join(_OFW_TREE, _SN)): @@ -72,7 +104,10 @@ def print_serial_number(): def get_build_number(): - build_no = _read_file('/boot/olpc_build') + if os.path.isfile('/boot/olpc_build'): + build_no = _read_file('/boot/olpc_build') + elif os.path.isfile('/bootpart/olpc_build'): + build_no = _read_file('/bootpart/olpc_build') if build_no is None: build_no = _read_file('/etc/redhat-release') @@ -97,6 +132,15 @@ def print_build_number(): print get_build_number() +def get_model_laptop(): + from ceibal import laptop + + model_laptop = laptops.get_model_laptop() + if model_laptop is None or not model_laptop: + model_laptop = _not_available + return model_laptop + + def _parse_firmware_number(firmware_no): if firmware_no is None: firmware_no = _not_available @@ -226,3 +270,86 @@ def get_license(): except IOError: license_text = _not_available return license_text + + +def get_last_updated_on_field(): + + # Get the number of UNIX seconds of the last update date. + last_update_unix_seconds = {} + try: + last_update_unix_seconds = int(os.stat('/var/lib/rpm/Packages').st_mtime) + except: + msg_str = _('Information not available.') + _logger.exception(msg_str) + return msg_str + + + NO_UPDATE_MESSAGE = _('No update yet!') + + + # Check once again that 'last_update_unix_seconds' is not empty. + # You never know ! + if not last_update_unix_seconds: + return NO_UPDATE_MESSAGE + + if int(last_update_unix_seconds) == 1194004800: + return NO_UPDATE_MESSAGE + + + # If we reached here, we have the last-update-time, but it's in + # timestamp format. + # Using python-subprocess-module (no shell involved), to convert + # it into readable date-format; the hack being used (after + # removing '-u' option) is the first one mentioned at : + # http://www.commandlinefu.com/commands/view/3719/convert-unix-timestamp-to-date + environment = os.environ.copy() + environment['PATH'] = '%s:/usr/sbin' % (environment['PATH'], ) + + last_update_readable_format = {} + try: + last_update_readable_format = \ + subprocess.Popen(['date', '-d', + '1970-01-01 + ' + + str(last_update_unix_seconds) + + ' seconds'], + stdout=subprocess.PIPE, + env=environment).stdout.readlines()[0] + except: + msg_str = _('Information not available.') + _logger.exception(msg_str) + return msg_str + + if not last_update_readable_format: + return _('Information not available.') + + # Everything should be fine (hopefully :-) ) + return last_update_readable_format + + +def get_sugar_version(): + return config.version + + +def get_plazo(): + from ceibal import env + path_plazo = env.get_security_root() + try: + plazo = _read_file(os.path.join(path_plazo, "blacklist")).split("\n")[0].strip() + plazo = time.strftime( "%d-%m-%Y",time.strptime(plazo,'%Y%m%d')) + except: + plazo = _not_available + + return plazo + +def get_act(): + from ceibal import env + path_act = env.get_updates_root() + parser = ConfigParser.ConfigParser() + salida = parser.read(os.path.join(path_act, "mi_version")) + if salida == []: + version = _not_available + else: + version = '' + for seccion in parser.sections(): + version = "%s%s: %s\n" %(version,seccion,parser.get(seccion,'version')) + return version diff --git a/extensions/cpsection/aboutcomputer/view.py b/extensions/cpsection/aboutcomputer/view.py index f44ca51..1e11301 100644 --- a/extensions/cpsection/aboutcomputer/view.py +++ b/extensions/cpsection/aboutcomputer/view.py @@ -23,7 +23,6 @@ from gi.repository import Gdk from sugar3.graphics import style -from jarabe import config from jarabe.controlpanel.sectionview import SectionView @@ -65,24 +64,51 @@ class AboutComputer(SectionView): vbox_identity.set_border_width(style.DEFAULT_SPACING * 2) vbox_identity.set_spacing(style.DEFAULT_SPACING) - box_identity = Gtk.HBox(spacing=style.DEFAULT_SPACING) - label_serial = Gtk.Label(label=_('Serial Number:')) - label_serial.set_alignment(1, 0) - label_serial.modify_fg(Gtk.StateType.NORMAL, - style.COLOR_SELECTION_GREY.get_gdk_color()) - box_identity.pack_start(label_serial, False, True, 0) - self._group.add_widget(label_serial) - label_serial.show() - label_serial_no = Gtk.Label(label=self._model.get_serial_number()) - label_serial_no.set_alignment(0, 0) - box_identity.pack_start(label_serial_no, False, True, 0) - label_serial_no.show() - vbox_identity.pack_start(box_identity, False, True, 0) - box_identity.show() + self._setup_component_if_applicable(None, + _('Serial Number:'), + self._model.get_serial_number, + vbox_identity) + + self._setup_component_if_applicable('/desktop/sugar/extensions/aboutcomputer/display_lease', + _('Lease:'), + self._model.get_lease_days, + vbox_identity) self._vbox.pack_start(vbox_identity, False, True, 0) vbox_identity.show() + def _is_feature_to_be_shown(slf, gconf_key): + if gconf_key is None: + return True + + from gi.repository import GConf + client = GConf.Client.get_default() + + return client.get_bool(gconf_key) is True + + def _setup_component_if_applicable(self, gconf_key, key, value_func, packer): + if not self._is_feature_to_be_shown(gconf_key): + return + + # Now that we do need to show, fetch the value. + print value_func + value = value_func() + + box = Gtk.HBox(spacing=style.DEFAULT_SPACING) + key_label = Gtk.Label(label=key) + key_label.set_alignment(1, 0) + key_label.modify_fg(Gtk.StateType.NORMAL, + style.COLOR_SELECTION_GREY.get_gdk_color()) + box.pack_start(key_label, False, True, 0) + self._group.add_widget(key_label) + key_label.show() + value_label = Gtk.Label(label=value) + value_label.set_alignment(0, 0) + box.pack_start(value_label, False, True, 0) + value_label.show() + packer.pack_start(box, False, True, 0) + box.show() + def _setup_software(self): separator_software = Gtk.HSeparator() self._vbox.pack_start(separator_software, False, True, 0) @@ -96,66 +122,45 @@ class AboutComputer(SectionView): box_software.set_border_width(style.DEFAULT_SPACING * 2) box_software.set_spacing(style.DEFAULT_SPACING) - box_build = Gtk.HBox(spacing=style.DEFAULT_SPACING) - label_build = Gtk.Label(label=_('Build:')) - label_build.set_alignment(1, 0) - label_build.modify_fg(Gtk.StateType.NORMAL, - style.COLOR_SELECTION_GREY.get_gdk_color()) - box_build.pack_start(label_build, False, True, 0) - self._group.add_widget(label_build) - label_build.show() - label_build_no = Gtk.Label(label=self._model.get_build_number()) - label_build_no.set_alignment(0, 0) - box_build.pack_start(label_build_no, False, True, 0) - label_build_no.show() - box_software.pack_start(box_build, False, True, 0) - box_build.show() - - box_sugar = Gtk.HBox(spacing=style.DEFAULT_SPACING) - label_sugar = Gtk.Label(label=_('Sugar:')) - label_sugar.set_alignment(1, 0) - label_sugar.modify_fg(Gtk.StateType.NORMAL, - style.COLOR_SELECTION_GREY.get_gdk_color()) - box_sugar.pack_start(label_sugar, False, True, 0) - self._group.add_widget(label_sugar) - label_sugar.show() - label_sugar_ver = Gtk.Label(label=config.version) - label_sugar_ver.set_alignment(0, 0) - box_sugar.pack_start(label_sugar_ver, False, True, 0) - label_sugar_ver.show() - box_software.pack_start(box_sugar, False, True, 0) - box_sugar.show() - - box_firmware = Gtk.HBox(spacing=style.DEFAULT_SPACING) - label_firmware = Gtk.Label(label=_('Firmware:')) - label_firmware.set_alignment(1, 0) - label_firmware.modify_fg(Gtk.StateType.NORMAL, - style.COLOR_SELECTION_GREY.get_gdk_color()) - box_firmware.pack_start(label_firmware, False, True, 0) - self._group.add_widget(label_firmware) - label_firmware.show() - label_firmware_no = Gtk.Label(label=self._model.get_firmware_number()) - label_firmware_no.set_alignment(0, 0) - box_firmware.pack_start(label_firmware_no, False, True, 0) - label_firmware_no.show() - box_software.pack_start(box_firmware, False, True, 0) - box_firmware.show() - - box_wireless_fw = Gtk.HBox(spacing=style.DEFAULT_SPACING) - label_wireless_fw = Gtk.Label(label=_('Wireless Firmware:')) - label_wireless_fw.set_alignment(1, 0) - label_wireless_fw.modify_fg(Gtk.StateType.NORMAL, - style.COLOR_SELECTION_GREY.get_gdk_color()) - box_wireless_fw.pack_start(label_wireless_fw, False, True, 0) - self._group.add_widget(label_wireless_fw) - label_wireless_fw.show() - wireless_fw_no = self._model.get_wireless_firmware() - label_wireless_fw_no = Gtk.Label(label=wireless_fw_no) - label_wireless_fw_no.set_alignment(0, 0) - box_wireless_fw.pack_start(label_wireless_fw_no, False, True, 0) - label_wireless_fw_no.show() - box_software.pack_start(box_wireless_fw, False, True, 0) - box_wireless_fw.show() + self._setup_component_if_applicable('/desktop/sugar/extensions/aboutcomputer/display_model', + _('Model:'), + self._model.get_model_laptop, + box_software) + + self._setup_component_if_applicable(None, + _('Build:'), + self._model.get_build_number, + box_software) + + self._setup_component_if_applicable(None, + _('Sugar:'), + self._model.get_sugar_version, + box_software) + + self._setup_component_if_applicable(None, + _('Firmware:'), + self._model.get_firmware_number, + box_software) + + self._setup_component_if_applicable('/desktop/sugar/extensions/aboutcomputer/display_wireless_firmware', + _('Wireless Firmware:'), + self._model.get_wireless_firmware, + box_software) + + self._setup_component_if_applicable('/desktop/sugar/extensions/aboutcomputer/display_plazo', + _('Plazo:'), + self._model.get_plazo, + box_software) + + self._setup_component_if_applicable('/desktop/sugar/extensions/aboutcomputer/display_version_de_actual', + _('VersiĆ³n de ActualizaciĆ³n:'), + self._model.get_act, + box_software) + + self._setup_component_if_applicable(None, + _('Last Updated On:'), + self._model.get_last_updated_on_field, + box_software) self._vbox.pack_start(box_software, False, True, 0) box_software.show() diff --git a/extensions/cpsection/datetime/model.py b/extensions/cpsection/datetime/model.py index c9b4586..f73bef7 100644 --- a/extensions/cpsection/datetime/model.py +++ b/extensions/cpsection/datetime/model.py @@ -21,10 +21,57 @@ # import os +import logging + from gettext import gettext as _ from gi.repository import GConf _zone_tab = '/usr/share/zoneinfo/zone.tab' +NTPDATE_PATH = '/usr/sbin/ntpdate' +NTP_SERVER_CONFIG_FILENAME = '/etc/ntp/step-tickers' + +_logger = logging.getLogger('ControlPanel - TimeZone') + + +def is_ntp_servers_config_feature_available(): + return os.path.exists(NTPDATE_PATH) + + +def get_ntp_servers(): + servers = [] + + # If the file does not exist, return. + if not os.path.exists(NTP_SERVER_CONFIG_FILENAME): + return servers + + f = open(NTP_SERVER_CONFIG_FILENAME, 'r') + for server in f.readlines(): + servers.append(server.rstrip('\n')) + f.close() + + return servers + + +def set_ntp_servers(servers): + + # First remove the old ssid-file, if it exists. + if os.path.exists(NTP_SERVER_CONFIG_FILENAME): + try: + os.remove(NTP_SERVER_CONFIG_FILENAME) + except: + _logger.exception('Error removing file.') + return + + # Do nothing and return, if the values-list is empty + if len(servers) == 0: + return + + # If we reach here, we have a non-empty ssid-values-list. + f = open(NTP_SERVER_CONFIG_FILENAME, 'w') + for server in servers: + if len(server) > 0: + f.write(server + '\n') + f.close() def _initialize(): diff --git a/extensions/cpsection/datetime/view.py b/extensions/cpsection/datetime/view.py index 64789b4..4ad94ca 100644 --- a/extensions/cpsection/datetime/view.py +++ b/extensions/cpsection/datetime/view.py @@ -20,11 +20,130 @@ from gettext import gettext as _ from sugar3.graphics import style from sugar3.graphics import iconentry +from sugar3.graphics.icon import Icon from jarabe.controlpanel.sectionview import SectionView from jarabe.controlpanel.inlinealert import InlineAlert +class AddRemoveWidget(Gtk.HBox): + + def __init__(self, label, add_button_clicked_cb, + remove_button_clicked_cb, index): + Gtk.Box.__init__(self) + self.set_homogeneous(False) + self.set_spacing(10) + + self._index = index + self._add_button_added = False + self._remove_button_added = False + + self._entry_box = Gtk.Entry() + self._entry_box.set_text(label) + self._entry_box.set_width_chars(40) + self.pack_start(self._entry_box, False, False, 0) + self._entry_box.show() + + add_icon = Icon(icon_name='list-add') + self._add_button = Gtk.Button() + self._add_button.set_image(add_icon) + self._add_button.connect('clicked', + add_button_clicked_cb, + self) + + remove_icon = Icon(icon_name='list-remove') + self._remove_button = Gtk.Button() + self._remove_button.set_image(remove_icon) + self._remove_button.connect('clicked', + remove_button_clicked_cb, + self) + self.__add_add_button() + self.__add_remove_button() + + def _get_index(self): + return self._index + + def _set_index(self, value): + self._index = value + + def _get_entry(self): + return self._entry_box.get_text() + + def __add_add_button(self): + self.pack_start(self._add_button, False, False, 0) + self._add_button.show() + self._add_button_added = True + + def _remove_remove_button_if_not_already(self): + if self._remove_button_added: + self.__remove_remove_button() + + def __remove_remove_button(self): + self.remove(self._remove_button) + self._remove_button_added = False + + def _add_remove_button_if_not_already(self): + if not self._remove_button_added: + self.__add_remove_button() + + def __add_remove_button(self): + self.pack_start(self._remove_button, False, False, 0) + self._remove_button.show() + self._remove_button_added = True + + +class MultiWidget(Gtk.VBox): + + def __init__(self): + Gtk.VBox.__init__(self) + + def _add_widget(self, label): + new_widget = AddRemoveWidget(label, + self.__add_button_clicked_cb, + self.__remove_button_clicked_cb, + len(self.get_children())) + self.add(new_widget) + new_widget.show() + self.show() + self._update_remove_button_statuses() + + def __add_button_clicked_cb(self, add_button, + add_button_container): + self._add_widget('') + self._update_remove_button_statuses() + + def __remove_button_clicked_cb(self, remove_button, + remove_button_container): + for child in self.get_children(): + if child._get_index() > remove_button_container._get_index(): + child._set_index(child._get_index() - 1) + + self.remove(remove_button_container) + self._update_remove_button_statuses() + + def _update_remove_button_statuses(self): + children = self.get_children() + + # Now, if there is only one entry, remove-button + # should not be shown. + if len(children) == 1: + children[0]._remove_remove_button_if_not_already() + + # Alternatively, if there are more than 1 entries, + # remove-button should be shown for all. + if len(children) > 1: + for child in children: + child._add_remove_button_if_not_already() + + + def _get_entries(self): + entries = [] + for child in self.get_children(): + entries.append(child._get_entry()) + + return entries + + class TimeZone(SectionView): def __init__(self, model, alerts): SectionView.__init__(self) @@ -64,7 +183,10 @@ class TimeZone(SectionView): self._treeview.set_search_entry(self._entry) self._treeview.set_search_equal_func(self._search, None) self._treeview.set_search_column(0) - self._scrolled_window.add(self._treeview) + self._timezone_box = Gtk.VBox() + self._scrolled_window.add(self._timezone_box) + self._timezone_box.show_all() + self._timezone_box.add(self._treeview) self._treeview.show() self._timezone_column = Gtk.TreeViewColumn(_('Timezone')) @@ -74,19 +196,29 @@ class TimeZone(SectionView): self._timezone_column.set_sort_column_id(0) self._treeview.append_column(self._timezone_column) - self.pack_start(self._scrolled_window, True, True, 0) - self._scrolled_window.show() + self._container = Gtk.VBox() + self._container.set_homogeneous(False) + self._container.pack_start(self._scrolled_window, True, True, 0) + self._container.set_spacing(style.DEFAULT_SPACING) + self._container.show_all() + self.pack_start(self._container, True, True, 0) self._zone_alert_box = Gtk.HBox(spacing=style.DEFAULT_SPACING) - self.pack_start(self._zone_alert_box, False, False, 0) - + self._timezone_box.pack_start(self._zone_alert_box, False, False, 0) self._zone_alert = InlineAlert() self._zone_alert_box.pack_start(self._zone_alert, True, True, 0) if 'zone' in self.restart_alerts: self._zone_alert.props.msg = self.restart_msg self._zone_alert.show() + + # Not showing this, as this hides the selected timezone. + # Instead, the alert will anyways be shown when user clicks + # on "Ok". + #self._zone_alert.show() self._zone_alert_box.show() + self._ntp_ui_setup = False + self.setup() def setup(self): @@ -102,6 +234,45 @@ class TimeZone(SectionView): self.needs_restart = False self._cursor_change_handler = self._treeview.connect( \ 'cursor-changed', self.__zone_changed_cd) + if self._model.is_ntp_servers_config_feature_available(): + self.setup_ui_for_ntp_server_config() + + def setup_ui_for_ntp_server_config(self): + if self._ntp_ui_setup: + return + self._ntp_ui_setup = True + + self._ntp_scrolled_window = Gtk.ScrolledWindow() + self._ntp_scrolled_window.set_policy(Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC) + box_ntp_servers_config = Gtk.VBox() + box_ntp_servers_config.set_spacing(style.DEFAULT_SPACING) + + separator_ntp_servers_config= Gtk.HSeparator() + self._container.pack_start(separator_ntp_servers_config, + False, False, 0) + separator_ntp_servers_config.show() + + label_ntp_servers_config = Gtk.Label(_('NTP Servers Configuration')) + label_ntp_servers_config.set_alignment(0, 0) + self._container.pack_start(label_ntp_servers_config, + False, False, 0) + label_ntp_servers_config.show() + + self._widget_table = MultiWidget() + box_ntp_servers_config.pack_start(self._widget_table, False, False, 0) + box_ntp_servers_config.show_all() + + self._ntp_scrolled_window.add_with_viewport(box_ntp_servers_config) + self._container.pack_start(self._ntp_scrolled_window, True, True, 0) + self._ntp_scrolled_window.show_all() + + ntp_servers = self._model.get_ntp_servers() + if len(ntp_servers) == 0: + self._widget_table._add_widget('') + else: + for server in ntp_servers: + self._widget_table._add_widget(server) def undo(self): self._treeview.disconnect(self._cursor_change_handler) @@ -136,5 +307,7 @@ class TimeZone(SectionView): self.restart_alerts.append('zone') self.needs_restart = True self._zone_alert.props.msg = self.restart_msg - self._zone_alert.show() return False + + def perform_accept_actions(self): + self._model.set_ntp_servers(self._widget_table._get_entries()) diff --git a/extensions/cpsection/modemconfiguration/Makefile.am b/extensions/cpsection/modemconfiguration/Makefile.am index 3e2613e..46f8e70 100644 --- a/extensions/cpsection/modemconfiguration/Makefile.am +++ b/extensions/cpsection/modemconfiguration/Makefile.am @@ -3,4 +3,5 @@ sugardir = $(pkgdatadir)/extensions/cpsection/modemconfiguration sugar_PYTHON = \ __init__.py \ model.py \ + config.py \ view.py diff --git a/extensions/cpsection/modemconfiguration/config.py b/extensions/cpsection/modemconfiguration/config.py new file mode 100644 index 0000000..9e27814 --- /dev/null +++ b/extensions/cpsection/modemconfiguration/config.py @@ -0,0 +1,25 @@ +# -*- encoding: utf-8 -*- +# Copyright (C) 2010 Andres Ambrois +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 US + + +PROVIDERS_PATH = "/usr/share/mobile-broadband-provider-info/serviceproviders.xml" +PROVIDERS_FORMAT_SUPPORTED = "2.0" +COUNTRY_CODES_PATH = "/usr/share/zoneinfo/iso3166.tab" + +GSM_COUNTRY_PATH = '/desktop/sugar/network/gsm/country' +GSM_PROVIDERS_PATH = '/desktop/sugar/network/gsm/providers' +GSM_PLAN_PATH = '/desktop/sugar/network/gsm/plan' diff --git a/extensions/cpsection/modemconfiguration/model.py b/extensions/cpsection/modemconfiguration/model.py index f457293..e33d881 100755 --- a/extensions/cpsection/modemconfiguration/model.py +++ b/extensions/cpsection/modemconfiguration/model.py @@ -19,14 +19,27 @@ import logging import dbus from gi.repository import Gtk +import os +import locale +import logging +import gconf + +from xml.etree.cElementTree import ElementTree +from gettext import gettext as _ + from jarabe.model import network +from cpsection.modemconfiguration.config import PROVIDERS_PATH, \ + PROVIDERS_FORMAT_SUPPORTED, \ + COUNTRY_CODES_PATH + + def get_connection(): return network.find_gsm_connection() -def get_modem_settings(): +def get_modem_settings(callback): modem_settings = {} connection = get_connection() if not connection: @@ -48,6 +61,10 @@ def get_modem_settings(): modem_settings['password'] = gsm_secrets.get('password', '') modem_settings['pin'] = gsm_secrets.get('pin', '') + # sl#3800: We return the settings, via the "_secrets_cb() + # method", instead of busy-waiting. + callback(modem_settings) + def _secrets_err_cb(err): secrets_call_done[0] = True if isinstance(err, dbus.exceptions.DBusException) and \ @@ -57,14 +74,11 @@ def get_modem_settings(): logging.error('Error retrieving GSM secrets: %s', err) # must be called asynchronously as this re-enters the GTK main loop + # + # sl#3800: We return the settings, via the "_secrets_cb()" method, + # instead of busy-waiting. connection.get_secrets('gsm', _secrets_cb, _secrets_err_cb) - # wait til asynchronous execution completes - while not secrets_call_done[0]: - Gtk.main_iteration() - - return modem_settings - def _set_or_clear(_dict, key, value): """Update a dictionary value for a specific key. If value is None or @@ -98,3 +112,162 @@ def set_modem_settings(modem_settings): _set_or_clear(gsm_settings, 'apn', apn) _set_or_clear(gsm_settings, 'pin', pin) connection.update_settings(settings) + + +def has_providers_db(): + if not os.path.isfile(COUNTRY_CODES_PATH): + logging.debug("Mobile broadband provider database: Country " \ + "codes path %s not found.", COUNTRY_CODES_PATH) + return False + try: + tree = ElementTree(file=PROVIDERS_PATH) + except (IOError, SyntaxError), e: + logging.debug("Mobile broadband provider database: Could not read " \ + "provider information %s error=%s", PROVIDERS_PATH) + return False + else: + elem = tree.getroot() + if elem is None or elem.get('format') != PROVIDERS_FORMAT_SUPPORTED: + logging.debug("Mobile broadband provider database: Could not " \ + "read provider information. %s is wrong format.", + elem.get('format')) + return False + return True + + +class CountryListStore(Gtk.ListStore): + COUNTRY_CODE = locale.getdefaultlocale()[0][3:5].lower() + + def __init__(self): + Gtk.ListStore.__init__(self, str, object) + codes = {} + with open(COUNTRY_CODES_PATH) as codes_file: + for line in codes_file: + if line.startswith('#'): + continue + code, name = line.split('\t')[:2] + codes[code.lower()] = name.strip() + etree = ElementTree(file=PROVIDERS_PATH).getroot() + self._country_idx = None + i = 0 + + # This dictionary wil store the values, with "country-name" as + # the key, and "country-code" as the value. + temp_dict = {} + + for elem in etree.findall('.//country'): + code = elem.attrib['code'] + if code == self.COUNTRY_CODE: + self._country_idx = i + else: + i += 1 + if code in codes: + temp_dict[codes[code]] = elem + else: + temp_dict[code] = elem + + # Now, sort the list by country-names. + country_name_keys = temp_dict.keys() + country_name_keys.sort() + + for country_name in country_name_keys: + self.append((country_name, temp_dict[country_name])) + + def get_row_providers(self, row): + return self[row][1] + + def guess_country_row(self): + if self._country_idx is not None: + return self._country_idx + else: + return 0 + + def search_index_by_code(self, code): + for index in range(0, len(self)): + if self[index][0] == code: + return index + return -1 + + +class ProviderListStore(Gtk.ListStore): + def __init__(self, elem): + Gtk.ListStore.__init__(self, str, object) + for provider_elem in elem.findall('.//provider'): + apns = provider_elem.findall('.//apn') + if not apns: + # Skip carriers with CDMA entries only + continue + self.append((provider_elem.find('.//name').text, apns)) + + def get_row_plans(self, row): + return self[row][1] + + def guess_providers_row(self): + # Simply return the first entry as the default. + return 0 + + def search_index_by_code(self, code): + for index in range(0, len(self)): + if self[index][0] == code: + return index + return -1 + + +class PlanListStore(Gtk.ListStore): + LANG_NS_ATTR = '{http://www.w3.org/XML/1998/namespace}lang' + LANG = locale.getdefaultlocale()[0][:2] + DEFAULT_NUMBER = '*99#' + + def __init__(self, elems): + Gtk.ListStore.__init__(self, str, object) + for apn_elem in elems: + plan = {} + names = apn_elem.findall('.//name') + if names: + for name in names: + if name.get(self.LANG_NS_ATTR) is None: + # serviceproviders.xml default value + plan['name'] = name.text + elif name.get(self.LANG_NS_ATTR) == self.LANG: + # Great! We found a name value for our locale! + plan['name'] = name.text + break + else: + plan['name'] = _('Default') + plan['apn'] = apn_elem.get('value') + user = apn_elem.find('.//username') + if user is not None: + plan['username'] = user.text + else: + plan['username'] = '' + passwd = apn_elem.find('.//password') + if passwd is not None: + plan['password'] = passwd.text + else: + plan['password'] = '' + + plan['number'] = self.DEFAULT_NUMBER + + self.append((plan['name'], plan)) + + def get_row_plan(self, row): + return self[row][1] + + def guess_plan_row(self): + # Simply return the first entry as the default. + return 0 + + def search_index_by_code(self, code): + for index in range(0, len(self)): + if self[index][0] == code: + return index + return -1 + + +def get_gconf_setting_string(gconf_key): + client = gconf.client_get_default() + return client.get_string(gconf_key) or '' + +def set_gconf_setting_string(gconf_key, gconf_setting_string_value): + client = gconf.client_get_default() + client.set_string(gconf_key, gconf_setting_string_value) diff --git a/extensions/cpsection/modemconfiguration/view.py b/extensions/cpsection/modemconfiguration/view.py index d5aa399..d218330 100644 --- a/extensions/cpsection/modemconfiguration/view.py +++ b/extensions/cpsection/modemconfiguration/view.py @@ -24,6 +24,10 @@ from sugar3.graphics import style from jarabe.controlpanel.sectionview import SectionView +from cpsection.modemconfiguration.config import GSM_COUNTRY_PATH, \ + GSM_PROVIDERS_PATH, \ + GSM_PLAN_PATH + APPLY_TIMEOUT = 1000 @@ -64,6 +68,17 @@ class ModemConfiguration(SectionView): self.set_border_width(style.DEFAULT_SPACING) self.set_spacing(style.DEFAULT_SPACING) self._group = Gtk.SizeGroup(Gtk.SizeGroupMode.HORIZONTAL) + self._combo_group = Gtk.SizeGroup(Gtk.SizeGroupMode.HORIZONTAL) + + scrolled_win = Gtk.ScrolledWindow() + scrolled_win.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled_win.show() + self.add(scrolled_win) + + main_box = Gtk.VBox(spacing=style.DEFAULT_SPACING) + main_box.set_border_width(style.DEFAULT_SPACING) + main_box.show() + scrolled_win.add_with_viewport(main_box) explanation = _('You will need to provide the following information' ' to set up a mobile broadband connection to a' @@ -71,41 +86,85 @@ class ModemConfiguration(SectionView): self._text = Gtk.Label(label=explanation) self._text.set_line_wrap(True) self._text.set_alignment(0, 0) - self.pack_start(self._text, False, False, 0) + main_box.pack_start(self._text, False, False, 0) self._text.show() + if model.has_providers_db(): + self._upper_box = Gtk.VBox(spacing=style.DEFAULT_SPACING) + self._upper_box.set_border_width(style.DEFAULT_SPACING) + main_box.pack_start(self._upper_box, True, True, 0) + self._upper_box.show() + + # Do not attach any 'change'-handlers for now. + # They will be attached (once per combobox), once the + # individual combobox is processed at startup. + self._country_store = model.CountryListStore() + self._country_combo = Gtk.ComboBox(model=self._country_store) + self._attach_combobox_widget(_('Country:'), + self._country_combo) + + self._providers_combo = Gtk.ComboBox() + self._attach_combobox_widget(_('Provider:'), + self._providers_combo) + + self._plan_combo = Gtk.ComboBox() + self._attach_combobox_widget(_('Plan:'), + self._plan_combo) + + separator = Gtk.HSeparator() + main_box.pack_start(separator, True, True, 0) + separator.show() + + self._lower_box = Gtk.VBox(spacing=style.DEFAULT_SPACING) + self._lower_box.set_border_width(style.DEFAULT_SPACING) + main_box.pack_start(self._lower_box, True, True, 0) + self._lower_box.show() + self._username_entry = EntryWithLabel(_('Username:')) - self._username_entry.entry.connect('changed', self.__entry_changed_cb) - self._group.add_widget(self._username_entry.label) - self.pack_start(self._username_entry, False, True, 0) - self._username_entry.show() + self._attach_entry_widget(self._username_entry) self._password_entry = EntryWithLabel(_('Password:')) - self._password_entry.entry.connect('changed', self.__entry_changed_cb) - self._group.add_widget(self._password_entry.label) - self.pack_start(self._password_entry, False, True, 0) - self._password_entry.show() + self._attach_entry_widget(self._password_entry) self._number_entry = EntryWithLabel(_('Number:')) - self._number_entry.entry.connect('changed', self.__entry_changed_cb) - self._group.add_widget(self._number_entry.label) - self.pack_start(self._number_entry, False, True, 0) - self._number_entry.show() + self._attach_entry_widget(self._number_entry) self._apn_entry = EntryWithLabel(_('Access Point Name (APN):')) - self._apn_entry.entry.connect('changed', self.__entry_changed_cb) - self._group.add_widget(self._apn_entry.label) - self.pack_start(self._apn_entry, False, True, 0) - self._apn_entry.show() + self._attach_entry_widget(self._apn_entry) self._pin_entry = EntryWithLabel(_('Personal Identity Number (PIN):')) - self._pin_entry.entry.connect('changed', self.__entry_changed_cb) - self._group.add_widget(self._pin_entry.label) - self.pack_start(self._pin_entry, False, True, 0) - self._pin_entry.show() + self._attach_entry_widget(self._pin_entry) self.setup() + def _attach_combobox_widget(self, label_text, combobox_obj): + box = Gtk.HBox(spacing=style.DEFAULT_SPACING) + label = Gtk.Label(label_text) + self._group.add_widget(label) + label.set_alignment(1, 0.5) + box.pack_start(label, False, False, 0) + label.show() + + self._combo_group.add_widget(combobox_obj) + cell = Gtk.CellRendererText() + cell.props.xalign = 0.5 + + cell.set_property('width-chars', 30) + + combobox_obj.pack_start(cell, True) + combobox_obj.add_attribute(cell, 'text', 0) + + box.pack_start(combobox_obj, False, False, 0) + combobox_obj.show() + self._upper_box.pack_start(box, False, False, 0) + box.show() + + def _attach_entry_widget(self, entry_with_label_obj): + entry_with_label_obj.entry.connect('changed', self.__entry_changed_cb) + self._group.add_widget(entry_with_label_obj.label) + self._lower_box.pack_start(entry_with_label_obj, True, True, 0) + entry_with_label_obj.show() + def undo(self): self._model.undo() @@ -113,12 +172,31 @@ class ModemConfiguration(SectionView): """Populate an entry with text, without triggering its 'changed' handler.""" entry = entrywithlabel.entry - entry.handler_block_by_func(self.__entry_changed_cb) + + # Do not block/unblock the callback functions. + # + # Thus, the savings will be persisted to the NM settings, + # whenever any setting on the UI changes (by user-intervention, + # or otherwise). + #entry.handler_block_by_func(self.__entry_changed_cb) entry.set_text(text) - entry.handler_unblock_by_func(self.__entry_changed_cb) + #entry.handler_unblock_by_func(self.__entry_changed_cb) def setup(self): - settings = self._model.get_modem_settings() + if self._model.has_providers_db(): + persisted_country = self._model.get_gconf_setting_string(GSM_COUNTRY_PATH) + if (self._model.has_providers_db()) and (persisted_country != ''): + self._country_combo.set_active(self._country_store.search_index_by_code(persisted_country)) + else: + self._country_combo.set_active(self._country_store.guess_country_row()) + + # Call the selected callback anyway, so as to chain-set the + # default values for providers and the plans. + self.__country_selected_cb(self._country_combo, setup=True) + + self._model.get_modem_settings(self.populate_entries) + + def populate_entries(self, settings): self._populate_entry(self._username_entry, settings.get('username', '')) self._populate_entry(self._number_entry, settings.get('number', '')) @@ -133,6 +211,78 @@ class ModemConfiguration(SectionView): self._timeout_sid = GObject.timeout_add(APPLY_TIMEOUT, self.__timeout_cb) + def _get_selected_text(self, combo): + active_iter = combo.get_active_iter() + return combo.get_model().get(active_iter, 0)[0] + + def __country_selected_cb(self, combo, setup=False): + country = self._get_selected_text(combo) + self._model.set_gconf_setting_string(GSM_COUNTRY_PATH, country) + + model = combo.get_model() + providers = model.get_row_providers(combo.get_active()) + self._providers_liststore = self._model.ProviderListStore(providers) + self._providers_combo.set_model(self._providers_liststore) + + # Set the default provider as well. + if setup: + persisted_provider = self._model.get_gconf_setting_string(GSM_PROVIDERS_PATH) + if persisted_provider == '': + self._providers_combo.set_active(self._providers_liststore.guess_providers_row()) + else: + self._providers_combo.set_active(self._providers_liststore.search_index_by_code(persisted_provider)) + else: + self._providers_combo.set_active(self._providers_liststore.guess_providers_row()) + + # Country-combobox processed once at startip; now, attach the + # change-handler. + self._country_combo.connect('changed', self.__country_selected_cb, False) + + # Call the callback, so that default provider may be set. + self.__provider_selected_cb(self._providers_combo, setup) + + def __provider_selected_cb(self, combo, setup=False): + provider = self._get_selected_text(combo) + self._model.set_gconf_setting_string(GSM_PROVIDERS_PATH, provider) + + model = combo.get_model() + plans = model.get_row_plans(combo.get_active()) + self._plan_liststore = self._model.PlanListStore(plans) + self._plan_combo.set_model(self._plan_liststore) + + # Set the default plan as well. + if setup: + persisted_plan = self._model.get_gconf_setting_string(GSM_PLAN_PATH) + if persisted_plan == '': + self._plan_combo.set_active(self._plan_liststore.guess_plan_row()) + else: + self._plan_combo.set_active(self._plan_liststore.search_index_by_code(persisted_plan)) + else: + self._plan_combo.set_active(self._plan_liststore.guess_plan_row()) + + # Providers-combobox processed once at startip; now, attach the + # change-handler. + self._providers_combo.connect('changed', self.__provider_selected_cb, False) + + # Call the callback, so that the default plan is set. + self.__plan_selected_cb(self._plan_combo, setup) + + def __plan_selected_cb(self, combo, setup=False): + plan = self._get_selected_text(combo) + self._model.set_gconf_setting_string(GSM_PLAN_PATH, plan) + + # Plan-combobox processed once at startip; now, attach the + # change-handler. + self._plan_combo.connect('changed', self.__plan_selected_cb, False) + + model = combo.get_model() + plan = model.get_row_plan(combo.get_active()) + + self._populate_entry(self._username_entry, plan['username']) + self._populate_entry(self._password_entry, plan['password']) + self._populate_entry(self._apn_entry, plan['apn']) + self._populate_entry(self._number_entry, plan['number']) + def __timeout_cb(self): self._timeout_sid = 0 settings = {} diff --git a/extensions/cpsection/network/model.py b/extensions/cpsection/network/model.py index ae9e64d..83c3cf1 100644 --- a/extensions/cpsection/network/model.py +++ b/extensions/cpsection/network/model.py @@ -18,6 +18,10 @@ import logging import dbus +import os +import subprocess +import logging + from gettext import gettext as _ from gi.repository import GConf @@ -30,6 +34,8 @@ _NM_IFACE = 'org.freedesktop.NetworkManager' KEYWORDS = ['network', 'jabber', 'radio', 'server'] +_logger = logging.getLogger('ControlPanel - Network') + class ReadError(Exception): def __init__(self, value): @@ -154,3 +160,14 @@ def set_publish_information(value): client = GConf.Client.get_default() client.set_bool('/desktop/sugar/collaboration/publish_gadget', value) return 0 + + +def launch_nm_connection_editor(): + environment = os.environ.copy() + environment['PATH'] = '%s:/usr/sbin' % (environment['PATH'], ) + + try: + subprocess.Popen(['-c', 'sudo nm-connection-editor --type=802-11-wireless'], + shell=True) + except: + _logger.exception('Error running nm-connection-editor') diff --git a/extensions/cpsection/network/view.py b/extensions/cpsection/network/view.py index 9b89375..e4332e4 100644 --- a/extensions/cpsection/network/view.py +++ b/extensions/cpsection/network/view.py @@ -17,6 +17,7 @@ from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GObject +from gi.repository import GConf from gettext import gettext as _ from sugar3.graphics import style @@ -30,6 +31,9 @@ ICON = 'module-network' TITLE = _('Network') _APPLY_TIMEOUT = 3000 +EXPLICIT_REBOOT_MESSAGE = _('Please restart your computer for changes to take effect.') + +gconf_client = GConf.Client.get_default() class Network(SectionView): @@ -51,6 +55,7 @@ class Network(SectionView): self._radio_alert_box = Gtk.HBox(spacing=style.DEFAULT_SPACING) self._jabber_alert_box = Gtk.HBox(spacing=style.DEFAULT_SPACING) + self._nm_connection_editor_alert_box = Gtk.HBox(spacing=style.DEFAULT_SPACING) scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) @@ -178,8 +183,54 @@ class Network(SectionView): workspace.pack_start(box_mesh, False, True, 0) box_mesh.show() + if gconf_client.get_bool('/desktop/sugar/extensions/network/show_nm_connection_editor') is True: + box_nm_connection_editor = self.add_nm_connection_editor_launcher(workspace) + self.setup() + def add_nm_connection_editor_launcher(self, workspace): + separator_nm_connection_editor = Gtk.HSeparator() + workspace.pack_start(separator_nm_connection_editor, False, True, 0) + separator_nm_connection_editor.show() + + label_nm_connection_editor = Gtk.Label(_('Advanced Network Settings')) + label_nm_connection_editor.set_alignment(0, 0) + workspace.pack_start(label_nm_connection_editor, False, True, 0) + label_nm_connection_editor.show() + + box_nm_connection_editor = Gtk.VBox() + box_nm_connection_editor.set_border_width(style.DEFAULT_SPACING * 2) + box_nm_connection_editor.set_spacing(style.DEFAULT_SPACING) + + info = Gtk.Label(_("For more specific network settings, use " + "the NetworkManager Connection Editor.")) + + info.set_alignment(0, 0) + info.set_line_wrap(True) + box_nm_connection_editor.pack_start(info, False, True, 0) + + self._nm_connection_editor_alert = InlineAlert() + self._nm_connection_editor_alert.props.msg = EXPLICIT_REBOOT_MESSAGE + self._nm_connection_editor_alert_box.pack_start(self._nm_connection_editor_alert, + False, True, 0) + box_nm_connection_editor.pack_end(self._nm_connection_editor_alert_box, + False, True, 0) + self._nm_connection_editor_alert_box.show() + self._nm_connection_editor_alert.show() + + launch_button = Gtk.Button() + launch_button.set_alignment(0, 0) + launch_button.set_label(_('Launch')) + launch_button.connect('clicked', self.__launch_button_clicked_cb) + box_launch_button = Gtk.HBox() + box_launch_button.set_homogeneous(False) + box_launch_button.pack_start(launch_button, False, True, 0) + box_launch_button.show_all() + + box_nm_connection_editor.pack_start(box_launch_button, False, True, 0) + workspace.pack_start(box_nm_connection_editor, False, True, 0) + box_nm_connection_editor.show_all() + def setup(self): self._entry.set_text(self._model.get_jabber()) try: @@ -260,3 +311,7 @@ class Network(SectionView): self._model.clear_networks() if not self._model.have_networks(): self._clear_history_button.set_sensitive(False) + + def __launch_button_clicked_cb(self, launch_button): + self._model.launch_nm_connection_editor() + diff --git a/extensions/deviceicon/Makefile.am b/extensions/deviceicon/Makefile.am index 96a1753..42f3a4b 100644 --- a/extensions/deviceicon/Makefile.am +++ b/extensions/deviceicon/Makefile.am @@ -5,6 +5,7 @@ sugar_PYTHON = \ battery.py \ frame.py \ network.py \ + resources.py \ speaker.py \ speech.py \ touchpad.py \ diff --git a/extensions/deviceicon/network.py b/extensions/deviceicon/network.py index 79bc764..8ca9077 100644 --- a/extensions/deviceicon/network.py +++ b/extensions/deviceicon/network.py @@ -23,6 +23,8 @@ import logging import hashlib import socket import struct +import random +import re import datetime import time from gi.repository import Gtk @@ -30,6 +32,7 @@ import glib from gi.repository import GObject from gi.repository import GConf import dbus +import uuid from sugar3.graphics.icon import get_icon_state from sugar3.graphics import style @@ -55,6 +58,14 @@ _GSM_STATE_CONNECTING = 2 _GSM_STATE_CONNECTED = 3 _GSM_STATE_FAILED = 4 +_GSM_SHARING_PRIVATE = 0 +_GSM_SHARING_TRYING = 1 +_GSM_SHARING_NEIGHBORHOOD = 2 + +_GSM_SHARING_CHANNELS = [2,3,4,5,7,8,9,10,12,13] + +_wifi_device = None + class WirelessPalette(Palette): __gtype_name__ = 'SugarWirelessPalette' @@ -200,6 +211,8 @@ class GsmPalette(Palette): __gsignals__ = { 'gsm-connect': (GObject.SignalFlags.RUN_FIRST, None, ([])), 'gsm-disconnect': (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'gsm-private': (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'gsm-neighborhood': (GObject.SignalFlags.RUN_FIRST, None, ([])), } def __init__(self): @@ -208,6 +221,7 @@ class GsmPalette(Palette): self._current_state = None self._failed_connection = False + self._sharing_state = _GSM_SHARING_PRIVATE self.info_box = Gtk.VBox() @@ -216,6 +230,11 @@ class GsmPalette(Palette): self.info_box.pack_start(self._toggle_state_item, True, True, 0) self._toggle_state_item.show() + self._sharing_box = Gtk.VBox() + self.info_box.pack_start(self._sharing_box, True, True, 0) + self.__update_sharing_toggle_widget(_('Private (Click to share)'), 'zoom-home') + self._sharing_box.hide() + self.error_title_label = Gtk.Label(label="") self.error_title_label.set_alignment(0, 0.5) self.error_title_label.set_line_wrap(True) @@ -298,6 +317,9 @@ class GsmPalette(Palette): icon = Icon(icon_name='media-eject', \ icon_size=Gtk.IconSize.MENU) self._toggle_state_item.set_image(icon) + self.sharing_update_text() + self._sharing_toggle_item.show() + return elif self._current_state == _GSM_STATE_FAILED: message_error = self._get_error_by_nm_reason(reason) @@ -306,6 +328,8 @@ class GsmPalette(Palette): raise ValueError('Invalid GSM state while updating label and ' \ 'text, %s' % str(self._current_state)) + self._sharing_toggle_item.hide() + def __toggle_state_cb(self, menuitem): if self._current_state == _GSM_STATE_NOT_READY: pass @@ -366,6 +390,50 @@ class GsmPalette(Palette): message_tuple = (network.get_error_by_reason(reason), message) return message_tuple + def sharing_update_text(self): + if self._sharing_state == _GSM_SHARING_PRIVATE: + self.__update_sharing_toggle_widget(_('Private (Click to share)'), 'zoom-home') + + elif self._sharing_state == _GSM_SHARING_TRYING: + self.__update_sharing_toggle_widget(_('Please wait...'), 'zoom-home') + + elif self._sharing_state == _GSM_SHARING_NEIGHBORHOOD: + self.__update_sharing_toggle_widget(_('Neighborhood (Click to unshare)'), 'zoom-neighborhood') + + else: + raise ValueError('Invalid GSM sharing state while updating, %s' % \ + str(self._sharing_state)) + + def __update_sharing_toggle_widget(self, label, icon_name): + for child_widget in self._sharing_box.get_children(): + self._sharing_box.remove(child_widget) + + self._sharing_toggle_item = PaletteMenuItem('') + self._sharing_toggle_item.connect('activate', self.__sharing_toggle_cb) + self._sharing_toggle_item.set_label(label) + icon = Icon(icon_name=icon_name, icon_size=Gtk.IconSize.MENU) + self._sharing_toggle_item.set_image(icon) + icon.show() + self._sharing_box.pack_start(self._sharing_toggle_item, True, True, 0) + separator = PaletteMenuItemSeparator() + self._sharing_box.pack_start(separator, True, True, 0) + separator.show() + self._sharing_box.show_all() + + def __sharing_toggle_cb(self, menuitem): + if self._sharing_state == _GSM_SHARING_PRIVATE: + self.emit('gsm-neighborhood') + + elif self._sharing_state == _GSM_SHARING_TRYING: + pass + + elif self._sharing_state == _GSM_SHARING_NEIGHBORHOOD: + self.emit('gsm-private') + + else: + raise ValueError('Invalid GSM sharing state, %s' % \ + str(self._sharing_state)) + class WirelessDeviceView(ToolButton): @@ -527,17 +595,8 @@ class WirelessDeviceView(ToolButton): else: state = network.NM_DEVICE_STATE_UNKNOWN - if self._mode != network.NM_802_11_MODE_ADHOC and \ - network.is_sugar_adhoc_network(self._ssid) == False: - if state == network.NM_DEVICE_STATE_ACTIVATED: - icon_name = '%s-connected' % 'network-wireless' - else: - icon_name = 'network-wireless' - - icon_name = get_icon_state(icon_name, self._strength) - if icon_name: - self._icon.props.icon_name = icon_name - else: + if self._mode == network.NM_802_11_MODE_ADHOC and \ + network.is_sugar_adhoc_network(self._ssid): channel = network.frequency_to_channel(self._frequency) if state == network.NM_DEVICE_STATE_ACTIVATED: self._icon.props.icon_name = 'network-adhoc-%s-connected' \ @@ -729,8 +788,9 @@ class GsmDeviceView(TrayIcon): def __init__(self, device): self._connection_time_handler = None - self._connection_timestamp = 0 + self._shared_connection_path = None + self._target_dev_path = None client = GConf.Client.get_default() color = xocolor.XoColor(client.get_string('/desktop/sugar/user/color')) @@ -758,6 +818,8 @@ class GsmDeviceView(TrayIcon): palette.set_group_id('frame') palette.connect('gsm-connect', self.__gsm_connect_cb) palette.connect('gsm-disconnect', self.__gsm_disconnect_cb) + palette.connect('gsm-neighborhood', self.__gsm_start_sharing_cb) + palette.connect('gsm-private', self.__gsm_stop_sharing_cb) self._palette = palette @@ -775,42 +837,32 @@ class GsmDeviceView(TrayIcon): def __gsm_connect_cb(self, palette, data=None): connection = network.find_gsm_connection() if connection is not None: - connection.activate(self._device.object_path, - reply_handler=self.__connect_cb, - error_handler=self.__connect_error_cb) + network.activate_connection_by_path(connection.get_path(), + self._device.object_path, + reply_handler=self._connect_cb, + error_handler=self._connect_error_cb) else: self._palette.add_alert(_('No GSM connection available.'), \ _('Create a connection in the ' \ 'control panel.')) - def __connect_cb(self, active_connection): + def _connect_cb(self, active_connection_path): + self._base_gsm_connection_path = active_connection_path logging.debug('Connected successfully to gsm device, %s', - active_connection) + active_connection_path) - def __connect_error_cb(self, error): + def _connect_error_cb(self, error): raise RuntimeError('Error when connecting to gsm device, %s' % error) def __gsm_disconnect_cb(self, palette, data=None): - obj = self._bus.get_object(network.NM_SERVICE, network.NM_PATH) - netmgr = dbus.Interface(obj, network.NM_IFACE) - netmgr_props = dbus.Interface(netmgr, dbus.PROPERTIES_IFACE) - active_connections_o = netmgr_props.Get(network.NM_IFACE, 'ActiveConnections') + network.get_manager().DeactivateConnection(self._base_gsm_connection_path, + reply_handler=self._disconnect_cb, + error_handler=self._disconnect_error_cb) - for conn_o in active_connections_o: - obj = self._bus.get_object(network.NM_IFACE, conn_o) - props = dbus.Interface(obj, dbus.PROPERTIES_IFACE) - devices = props.Get(network.NM_ACTIVE_CONN_IFACE, 'Devices') - if self._device.object_path in devices: - netmgr.DeactivateConnection( - conn_o, - reply_handler=self.__disconnect_cb, - error_handler=self.__disconnect_error_cb) - break - - def __disconnect_cb(self): + def _disconnect_cb(self): logging.debug('Disconnected successfully gsm device') - def __disconnect_error_cb(self, error): + def _disconnect_error_cb(self, error): raise RuntimeError('Error when disconnecting gsm device, %s' % error) def __state_changed_cb(self, new_state, old_state, reason): @@ -831,6 +883,10 @@ class GsmDeviceView(TrayIcon): gsm_state = _GSM_STATE_CONNECTED connection = network.find_gsm_connection() if connection is not None: + # Introspect the settings's keys once; else sometimes + # the key 'timestamp' gets missed. + connection.get_settings('connection').keys() + self._connection_timestamp = time.time() - \ connection.get_settings('connection')['timestamp'] self._connection_time_handler = GObject.timeout_add_seconds( \ @@ -879,6 +935,95 @@ class GsmDeviceView(TrayIcon): self._palette.update_connection_time(connection_time) return True + def __gsm_start_sharing_cb(self, palette): + if self._palette._sharing_state == _GSM_SHARING_PRIVATE: + logging.debug('GSM will start sharing now') + self._palette._sharing_state = _GSM_SHARING_TRYING + self._palette.sharing_update_text() + + self._target_device = _wifi_device + self._target_device_path = self._target_device.object_path + + client = GConf.Client.get_default() + nick = client.get_string('/desktop/sugar/user/nick') + nick = re.sub('\W', '', nick) + + name_format = '%s network' + format_length = len(name_format) - len('%s') + nick_length = 31 - format_length + name = name_format % nick[:nick_length] + + connection = network.find_connection_by_ssid(name) + if connection == None: + settings = network.Settings() + settings.connection.id = name + settings.connection.uuid = str(uuid.uuid4()) + settings.connection.type = '802-11-wireless' + settings.wireless.ssid = dbus.ByteArray(name) + settings.wireless.mode = 'adhoc' + settings.wireless.band = 'bg' + chosen_channel = random.randrange(len(_GSM_SHARING_CHANNELS)) + settings.wireless.channel = _GSM_SHARING_CHANNELS[chosen_channel] + settings.ip4_config = network.IP4Config() + settings.ip4_config.method = 'shared' + network.add_and_activate_connection(self._target_device, + settings, + '/', + self._gsm_sharing_ok_cb_for_add_and_activate, + self._gsm_sharing_error_cb) + else: + network.activate_connection_by_path(connection.get_path(), + self._target_device, + self._gsm_sharing_ok_cb, + self._gsm_sharing_error_cb) + + def _gsm_sharing_ok_cb_for_add_and_activate(self, + new_connection_path, + active_connection_path): + self._gsm_sharing_ok_cb(active_connection_path) + + def _gsm_sharing_ok_cb(self, connection_path): + logging.debug('GSM sharing is enabled') + self._shared_connection_path = connection_path + self._bus.add_signal_receiver(self._gsm_sharing_changed_cb, + signal_name='StateChanged', + path=self._target_device_path, + dbus_interface=network.NM_DEVICE_IFACE) + self._palette._sharing_state = _GSM_SHARING_NEIGHBORHOOD + self._palette.sharing_update_text() + + def _gsm_sharing_changed_cb(self, new_state, old_state, reason): + if new_state == network.NM_DEVICE_STATE_DISCONNECTED: + self._gsm_sharing_reset() + + def _gsm_sharing_reset(self): + logging.debug('GSM sharing is disabled') + if self._target_dev_path != None: + self._bus.remove_signal_receiver(self._gsm_sharing_changed_cb, + signal_name='StateChanged', + path=self._target_dev_path, + dbus_interface=network.NM_DEVICE_IFACE) + self._shared_connection_path = None + self._target_dev_path = None + self._palette._sharing_state = _GSM_SHARING_PRIVATE + self._palette.sharing_update_text() + + def _gsm_sharing_error_cb(self, error): + logging.debug('GSM sharing could not start: %s' % str(error)) + self._gsm_sharing_reset() + + def __gsm_stop_sharing_cb(self, palette): + logging.debug('GSM will stop sharing now') + network.get_manager().DeactivateConnection(self._shared_connection_path, + reply_handler=self._gsm_stop_sharing_ok_cb, + error_handler=self._gsm_stop_sharing_error_cb) + + def _gsm_stop_sharing_ok_cb(self): + self._gsm_sharing_reset() + + def _gsm_stop_sharing_error_cb(self): + logging.debug('GSM sharing could not stop') + class WirelessDeviceObserver(object): def __init__(self, device, tray): @@ -1056,6 +1201,8 @@ class NetworkManagerObserver(object): device = WiredDeviceObserver(nm_device, self._tray) self._devices[device_op] = device elif device_type == network.NM_DEVICE_TYPE_WIFI: + global _wifi_device + _wifi_device = nm_device device = WirelessDeviceObserver(nm_device, self._tray) self._devices[device_op] = device elif device_type == network.NM_DEVICE_TYPE_OLPC_MESH: @@ -1075,5 +1222,10 @@ class NetworkManagerObserver(object): del self._devices[device_op] +def get_wifi_device(): + global _wifi_device + return _wifi_device + + def setup(tray): device_observer = NetworkManagerObserver(tray) diff --git a/extensions/deviceicon/resources.py b/extensions/deviceicon/resources.py new file mode 100644 index 0000000..ef4adac --- /dev/null +++ b/extensions/deviceicon/resources.py @@ -0,0 +1,217 @@ +# Copyright (C) Anish Mangal +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from gettext import gettext as _ +import logging +import os + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import GConf + +from sugar3.graphics.tray import TrayIcon +from sugar3.graphics.xocolor import XoColor +from sugar3.graphics.palette import Palette +from sugar3.graphics import style + + +_SYSTEM_MOODS = ['-sad', '-normal', '-happy'] +_ICON_NAME = 'computer' +_UPDATE_INTERVAL = 5 * 1000 + + +class DeviceView(TrayIcon): + + FRAME_POSITION_RELATIVE = 500 + + def __init__(self): + client = GConf.Client.get_default() + self._color = XoColor(client.get_string('/desktop/sugar/user/color')) + TrayIcon.__init__(self, icon_name=_ICON_NAME, xo_color=self._color) + self.create_palette() + self._icon_widget.connect('button-release-event', self._click_cb) + + def create_palette(self): + self.palette = ResourcePalette(_('System resources')) + self.palette.set_group_id('frame') + self.palette.add_timer() + self.palette.connect('system-mood-changed', + self._system_mood_changed_cb) + return self.palette + + def _system_mood_changed_cb(self, palette_, mood): + self.icon.props.icon_name = _ICON_NAME + mood + + def _click_cb(self, widget, event): + self.palette_invoker.notify_right_click() + + +class ResourcePalette(Palette): + __gsignals__ = { + 'system-mood-changed': (GObject.SignalFlags.RUN_FIRST, None, ([str])), + } + + def __init__(self, primary_text): + Palette.__init__(self, label=primary_text) + + self.vbox = Gtk.VBox() + self.set_content(self.vbox) + + self._cpu_text = Gtk.Label() + self.vbox.pack_start(self._cpu_text, True, True, 0) + self._cpu_bar = Gtk.ProgressBar() + self._cpu_bar.set_size_request( + style.zoom(style.GRID_CELL_SIZE * 4), -1) + self.vbox.pack_start(self._cpu_bar, True, True, 0) + + self._memory_text = Gtk.Label() + self.vbox.pack_start(self._memory_text, True, True, 0) + self._memory_bar = Gtk.ProgressBar() + self._memory_bar.set_size_request( + style.zoom(style.GRID_CELL_SIZE * 4), -1) + self.vbox.pack_start(self._memory_bar, True, True, 0) + + self._system_mood = None + try: + self._cpu_times = self._get_cpu_times_list() + except IOError: + logging.exception('An error ocurred while attempting to ' + 'read /proc/stat') + self._stop_computing_statistics() + + self.vbox.show() + self._cpu_text.show() + self._cpu_bar.show() + self._memory_text.show() + self._memory_bar.show() + + def add_timer(self): + GObject.timeout_add(_UPDATE_INTERVAL, self.__timer_cb) + + def _get_cpu_times_list(self): + """Return various cpu times as read from /proc/stat + + This method returns the following cpu times measured + in jiffies (1/100 of a second for x86 systems) + as an ordered list of numbers - [user, nice, + system, idle, iowait] where, + + user: normal processes executing in user mode + nice: niced processes executing in user mode + system: processes executing in kernel mode + idle: twiddling thumbs + iowait: waiting for I/O to complete + + Note: For systems having 2 or more CPU's, the above + numbers would be the cumulative sum of these times + for all CPU's present in the system. + + """ + return [int(count) + for count in file('/proc/stat').readline().split()[1:6]] + + def _percentage_cpu_available(self): + """ + Return free CPU resources as a percentage + + """ + _cpu_times_new = self._get_cpu_times_list() + _cpu_times_current = [(new - old) + for new, old in zip(_cpu_times_new, self._cpu_times)] + user_, nice_, system_, idle, iowait = _cpu_times_current + cpu_free = (idle + iowait) * 100.0 / sum(_cpu_times_current) + self._cpu_times = self._get_cpu_times_list() + return cpu_free + + def _percentage_memory_available(self): + """ + Return free memory as a percentage + """ + + for line in file('/proc/meminfo'): + name, value, unit_ = line.split()[:3] + if 'MemTotal:' == name: + total = int(value) + elif 'MemFree:' == name: + free = int(value) + elif 'Buffers:' == name: + buffers = int(value) + elif 'Cached:' == name: + cached = int(value) + elif 'Active:' == name: + break + return (free + buffers + cached) * 100.0 / total + + def __timer_cb(self): + try: + cpu_in_use = 100 - self._percentage_cpu_available() + memory_in_use = 100 - self._percentage_memory_available() + except IOError: + logging.exception('An error ocurred while trying to ' + 'retrieve resource usage statistics') + self._stop_and_show_error() + return False + else: + self._cpu_text.set_label(_('CPU in use: %d%%' % cpu_in_use)) + self._cpu_bar.set_fraction(float(cpu_in_use) / 100) + self._memory_text.set_label(_('Memory in use: %d%%' % + memory_in_use)) + self._memory_bar.set_fraction(float(memory_in_use) / 100) + + # both cpu_free and memory_free lie between 0-100 + system_mood = _SYSTEM_MOODS[ + int(300 - (cpu_in_use + 2 * memory_in_use)) // 100] + + # check if self._system_mood exists + try: + if self._system_mood != system_mood: + self.emit('system-mood-changed', system_mood) + self._system_mood = system_mood + except AttributeError: + self.emit('system-mood-changed', system_mood) + self._system_mood = system_mood + + return True + + def _stop_and_show_error(self): + """ + Stop computing usage statistics and display an error message + since we've hit an exception. + + """ + # Use the existing _cpu_text label to display the error. Remove + # everything else. + self._cpu_text.set_size_request( + style.zoom(style.GRID_CELL_SIZE * 4), -1) + self._cpu_text.set_line_wrap(True) + self._cpu_text.set_text(_('Cannot compute CPU and memory usage ' + 'statistics!')) + self.vbox.remove(self._cpu_bar) + self.vbox.remove(self._memory_text) + self.vbox.remove(self._memory_bar) + self.emit('system-mood-changed', '-error') + + +def setup(tray): + client = GConf.Client.get_default() + if not client.get_bool('/desktop/sugar/frame/show_network_resources'): + return + + if not (os.path.exists('/proc/stat') and os.path.exists('/proc/meminfo')): + logging.warning('Either /proc/stat or /proc/meminfo not present. Not ' + 'adding the CPU and memory usage icon to the frame') + return + tray.add_device(DeviceView()) diff --git a/po/POTFILES.in b/po/POTFILES.in index 17c10c3..2408820 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -28,6 +28,7 @@ extensions/cpsection/updater/view.py extensions/deviceicon/battery.py extensions/deviceicon/frame.py extensions/deviceicon/network.py +extensions/deviceicon/resources.py extensions/deviceicon/speech.py extensions/deviceicon/speaker.py extensions/deviceicon/touchpad.py diff --git a/src/Makefile.am b/src/Makefile.am index 83571a4..501c1a0 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -1 +1 @@ -SUBDIRS = jarabe +SUBDIRS = jarabe webdav diff --git a/src/jarabe/controlpanel/gui.py b/src/jarabe/controlpanel/gui.py index f8afca3..daa58e4 100644 --- a/src/jarabe/controlpanel/gui.py +++ b/src/jarabe/controlpanel/gui.py @@ -208,6 +208,10 @@ class ControlPanel(Gtk.Window): self.__accept_clicked_cb) def show_section_view(self, option): + self.get_window().set_cursor(Gdk.Cursor.new(Gdk.CursorType.WATCH)) + GObject.idle_add(self._finally_show_section_view, option) + + def _finally_show_section_view(self, option): self._set_toolbar(self._section_toolbar) icon = self._section_toolbar.get_icon() @@ -291,6 +295,7 @@ class ControlPanel(Gtk.Window): self._show_main_view() def __accept_clicked_cb(self, widget): + self._section_view.perform_accept_actions() if self._section_view.needs_restart: self._section_toolbar.accept_button.set_sensitive(False) self._section_toolbar.cancel_button.set_sensitive(False) diff --git a/src/jarabe/controlpanel/sectionview.py b/src/jarabe/controlpanel/sectionview.py index cbf4768..074a8ae 100644 --- a/src/jarabe/controlpanel/sectionview.py +++ b/src/jarabe/controlpanel/sectionview.py @@ -52,3 +52,7 @@ class SectionView(Gtk.VBox): def undo(self): """Undo here the changes that have been made in this section.""" pass + + def perform_accept_actions(self): + """Perform additional actions, when the "Ok" button is clicked.""" + pass diff --git a/src/jarabe/desktop/activitieslist.py b/src/jarabe/desktop/activitieslist.py index 4d2eb1a..4adaf18 100644 --- a/src/jarabe/desktop/activitieslist.py +++ b/src/jarabe/desktop/activitieslist.py @@ -215,6 +215,15 @@ class ListModel(Gtk.TreeModelSort): self._model.remove(row.iter) return + def _is_activity_bundle_in_model_already(self, activity_info): + bundle_id = activity_info.get_bundle_id() + version = activity_info.get_activity_version() + for row in self._model: + if row[ListModel.COLUMN_BUNDLE_ID] == bundle_id and \ + row[ListModel.COLUMN_VERSION] == version: + return True + return False + def _add_activity(self, activity_info): if activity_info.get_bundle_id() == 'org.laptop.JournalActivity': return @@ -223,6 +232,12 @@ class ListModel(Gtk.TreeModelSort): version = activity_info.get_activity_version() registry = bundleregistry.get_registry() + + # If the activity bundle is already a part of + # activities-list, do not re-add it. + if self._is_activity_bundle_in_model_already(activity_info): + return + favorite = registry.is_bundle_favorite(activity_info.get_bundle_id(), version) @@ -235,7 +250,7 @@ class ListModel(Gtk.TreeModelSort): '%s' % \ (activity_info.get_name(), tags) - self._model.append([activity_info.get_bundle_id(), + self._model.prepend([activity_info.get_bundle_id(), favorite, activity_info.get_icon(), title, diff --git a/src/jarabe/desktop/keydialog.py b/src/jarabe/desktop/keydialog.py index a4c8e36..a6cde8d 100644 --- a/src/jarabe/desktop/keydialog.py +++ b/src/jarabe/desktop/keydialog.py @@ -20,10 +20,14 @@ from gettext import gettext as _ from gi.repository import Gtk import dbus +import os +import shutil +from sugar3 import env from sugar3.graphics.icon import Icon from jarabe.model import network +from jarabe.journal.objectchooser import ObjectChooser IW_AUTH_ALG_OPEN_SYSTEM = 'open' @@ -33,6 +37,10 @@ WEP_PASSPHRASE = 1 WEP_HEX = 2 WEP_ASCII = 3 +SETTING_TYPE_STRING = 1 +SETTING_TYPE_LIST = 2 +SETTING_TYPE_CHOOSER = 3 + def string_is_hex(key): is_hex = True @@ -74,6 +82,216 @@ class CanceledKeyRequestError(dbus.DBusException): self._dbus_error_name = network.NM_SETTINGS_IFACE + '.CanceledError' +class NetworkParameters(Gtk.HBox): + def __init__(self, auth_param): + Gtk.HBox.__init__(self, homogeneous=True) + self._key = auth_param._key_name + self._label = Gtk.Label(_(auth_param._key_label)) + self._key_type = auth_param._key_type + self._auth_param = auth_param + + self.pack_start(self._label, True, True, 0) + self._label.show() + + if self._is_entry(): + self._entry = Gtk.Entry() + self.pack_start(self._entry, True, True, 0) + self._entry.show() + elif self._is_liststore(): + self._option_store = Gtk.ListStore(str, str) + for option in auth_param._options: + self._option_store.append(option) + + self._entry = auth_param._options[0][1] + self._option_combo = Gtk.ComboBox(model=self._option_store) + cell = Gtk.CellRendererText() + self._option_combo.pack_start(cell, True) + self._option_combo.add_attribute(cell, 'text', 0) + self._option_combo.set_active(0) + self._option_combo.connect('changed', + self._option_combo_changed_cb) + self.pack_start(self._option_combo, True, True, 0) + self.show() + self._option_combo.show() + elif self._is_chooser(): + self._chooser_button = Gtk.Button(_('Choose..')) + self._chooser_button.connect('clicked', + self._object_chooser_cb) + self.pack_start(self._chooser_button, True, True, 0) + self._chooser_button.show() + self._entry = '' + + def _is_entry(self): + return ( not self._is_chooser() ) and \ + ( len(self._auth_param._options) == 0 ) + + def _is_liststore(self): + return ( not self._is_chooser() ) and \ + ( len(self._auth_param._options) > 0 ) + + def _is_chooser(self): + return self._key_type == SETTING_TYPE_CHOOSER + + def _object_chooser_cb(self, chooser_button): + self._want_document = True + self._show_picker_cb() + + def _show_picker_cb(self): + if not self._want_document: + return + self._chooser = ObjectChooser() + self._chooser._set_callback(self.__process_selected_journal_object) + + self._chooser.show() + + def __process_selected_journal_object(self, object_id): + jobject = self._chooser.get_selected_object() + if jobject and jobject.file_path: + file_basename = \ + os.path.basename(jobject._metadata._properties['title']) + self._chooser_button.set_label(file_basename) + + profile_path = env.get_profile_path() + self._entry = os.path.join(profile_path, file_basename) + + # Remove (older) file, if it exists. + if os.path.exists(self._entry): + os.remove(self._entry) + + # Copy the file. + shutil.copy2(jobject.file_path, self._entry) + + self._chooser.destroy() + + def _option_combo_changed_cb(self, widget): + it = self._option_combo.get_active_iter() + (value, ) = self._option_store.get(it, 1) + self._entry = value + + def _get_key(self): + return self._key + + def _get_value(self): + if self._is_entry(): + return self._entry.get_text() + elif self._is_liststore(): + return self._entry + elif self._is_chooser(): + if len(self._entry) > 0: + return dbus.ByteArray('file://' + self._entry + '\0') + else: + return self._entry + + +class KeyValuesDialog(Gtk.Dialog): + def __init__(self, auth_lists, final_callback, settings): + # This must not be "modal", else the "chooser" widgets won't + # accept anything !! + Gtk.Dialog.__init__(self) + self.set_title(_('Wireless Parameters required')) + + self._spacing_between_children_widgets = 5 + self._auth_lists = auth_lists + self._final_callback = final_callback + self._settings = settings + + label = Gtk.Label(_("Please enter parameters\n")) + self.vbox.set_spacing(self._spacing_between_children_widgets) + self.vbox.pack_start(label, True, True, 0) + + self._auth_type_store = Gtk.ListStore(str, str) + for auth_list in self._auth_lists: + self._auth_type_store.append([auth_list._auth_label, + auth_list._auth_type]) + + self._auth_type_combo = Gtk.ComboBox(model=self._auth_type_store) + cell = Gtk.CellRendererText() + self._auth_type_combo.pack_start(cell, True) + self._auth_type_combo.add_attribute(cell, 'text', 0) + self._auth_type_combo.set_active(0) + self._auth_type_combo.connect('changed', + self._auth_type_combo_changed_cb) + self._auth_type_box = Gtk.HBox(homogeneous=True) + self._auth_label = Gtk.Label(_('Authentication')) + self._auth_type_box.pack_start(self._auth_label, True, True, 0) + self._auth_type_box.pack_start(self._auth_type_combo, + True, True, 0) + self.vbox.pack_start(self._auth_type_box, True, True, 0) + self._auth_label.show() + self._auth_type_combo.show() + + button = Gtk.Button() + button.set_image(Icon(icon_name='dialog-cancel')) + button.set_label(_('Cancel')) + self.add_action_widget(button, Gtk.ResponseType.CANCEL) + button = Gtk.Button() + button.set_image(Icon(icon_name='dialog-ok')) + button.set_label(_('Ok')) + self.add_action_widget(button, Gtk.ResponseType.OK) + self.set_default_response(Gtk.ResponseType.OK) + + self.connect('response', self._fetch_values) + + auth_type = self._auth_lists[0]._auth_type + self._selected_auth_list = self._select_auth_list(auth_type) + self._add_key_value('eap', auth_type) + self._add_container_box() + + def _auth_type_combo_changed_cb(self, widget): + it = self._auth_type_combo.get_active_iter() + (auth_type, ) = self._auth_type_store.get(it, 1) + self._selected_auth_list = self._select_auth_list(auth_type) + self._add_key_value('eap', auth_type) + self._reset() + + def _select_auth_list(self, auth_type): + for auth_list in self._auth_lists: + if auth_list._params_list[0]._options[0][1] == auth_type: + return auth_list + + def _populate_auth_params(self, auth_list): + for auth_param in auth_list._params_list[1:]: + obj = NetworkParameters(auth_param) + self._key_values_box.pack_start(obj, True, True, 0) + obj.show() + + def _reset(self): + self.vbox.remove(self._key_values_box) + self._add_container_box() + + def _add_container_box(self): + self._key_values_box = \ + Gtk.VBox(spacing=self._spacing_between_children_widgets) + self.vbox.pack_start(self._key_values_box, True, True, 0) + self._key_values_box.show() + self._populate_auth_params(self._selected_auth_list) + + def _remove_all_params(self): + self._key_values_box.remove_all() + + def _fetch_values(self, key_dialog, response_id): + if response_id == Gtk.ResponseType.OK: + for child in self._key_values_box.get_children(): + key = child._get_key() + value = child._get_value() + self._add_key_value(key, value) + + key_dialog.destroy() + self._final_callback(self._settings, + self._selected_auth_list) + + def _add_key_value(self, key, value): + for auth_param in self._selected_auth_list._params_list: + if auth_param._key_name == key: + if (auth_param._key_type == SETTING_TYPE_STRING) or \ + (auth_param._key_type == SETTING_TYPE_CHOOSER): + auth_param._value = value + elif auth_param._key_type == SETTING_TYPE_LIST: + values = [] + values.append(value) + auth_param._value = values + + class KeyDialog(Gtk.Dialog): def __init__(self, ssid, flags, wpa_flags, rsn_flags, dev_caps, response): Gtk.Dialog.__init__(self, flags=Gtk.DialogFlags.MODAL) @@ -219,7 +437,7 @@ class WEPKeyDialog(KeyDialog): self.set_response_sensitive(Gtk.ResponseType.OK, valid) -class WPAKeyDialog(KeyDialog): +class WPAPersonalKeyDialog(KeyDialog): def __init__(self, ssid, flags, wpa_flags, rsn_flags, dev_caps, response): KeyDialog.__init__(self, ssid, flags, wpa_flags, rsn_flags, dev_caps, response) @@ -295,14 +513,26 @@ def create(ssid, flags, wpa_flags, rsn_flags, dev_caps, response): rsn_flags == network.NM_802_11_AP_SEC_NONE: key_dialog = WEPKeyDialog(ssid, flags, wpa_flags, rsn_flags, dev_caps, response) - else: - key_dialog = WPAKeyDialog(ssid, flags, wpa_flags, rsn_flags, - dev_caps, response) + elif (wpa_flags & network.NM_802_11_AP_SEC_KEY_MGMT_PSK) or \ + (rsn_flags & network.NM_802_11_AP_SEC_KEY_MGMT_PSK): + key_dialog = WPAPersonalKeyDialog(ssid, flags, wpa_flags, rsn_flags, + dev_caps, settings, response) + elif (wpa_flags & network.NM_802_11_AP_SEC_KEY_MGMT_802_1X) or \ + (rsn_flags & network.NM_802_11_AP_SEC_KEY_MGMT_802_1X): + # nothing. All details are asked for WPA/WPA2-Enterprise + # networks, before the conneection-activation is done. + return key_dialog.connect('response', _key_dialog_response_cb) key_dialog.show_all() +def get_key_values(key_list, final_callback, settings): + key_dialog = KeyValuesDialog(key_list, final_callback, + settings) + key_dialog.show_all() + + def _key_dialog_response_cb(key_dialog, response_id): response = key_dialog.get_response_object() secrets = None diff --git a/src/jarabe/desktop/networkviews.py b/src/jarabe/desktop/networkviews.py index 64b4be3..eccfd17 100644 --- a/src/jarabe/desktop/networkviews.py +++ b/src/jarabe/desktop/networkviews.py @@ -23,6 +23,7 @@ import uuid import dbus import glib +import string from sugar3.graphics.icon import Icon from sugar3.graphics.xocolor import XoColor @@ -48,6 +49,192 @@ _OLPC_MESH_ICON_NAME = 'network-mesh' _FILTERED_ALPHA = 0.33 +SETTING_TYPE_STRING = 1 +SETTING_TYPE_LIST = 2 +SETTING_TYPE_CHOOSER = 3 + + +class AuthenticationType: + def __init__(self, auth_label, auth_type, params_list): + self._auth_label = auth_label + self._auth_type = auth_type + self._params_list = params_list + + +class AuthenticationParameter: + def __init__(self, key_name, key_label, key_type, + options): + self._key_name = key_name + self._key_label = key_label + self._key_type = key_type + self._options = options + self._value = None + + + +AUTHENTICATION_LIST = \ + [ + AuthenticationType('TLS', + 'tls', + [ + AuthenticationParameter( + 'eap', + 'Authentication', + SETTING_TYPE_LIST, + [['TLS', 'tls']] + ), + AuthenticationParameter( + 'identity', + 'Identity', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'client-cert', + 'User certificate', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'ca-cert', + 'CA certificate', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'private-key', + 'Private key', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'private-key-password', + 'Private Key password', + SETTING_TYPE_STRING, + [] + ) + ] + ), + AuthenticationType('LEAP', + 'leap', + [ + AuthenticationParameter( + 'eap', + 'Authentication', + SETTING_TYPE_LIST, + [['LEAP', 'leap']] + ), + AuthenticationParameter( + 'identity', + 'Username', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'password', + 'Password', + SETTING_TYPE_STRING, + [] + ) + ] + ), + AuthenticationType('Tunnelled TLS', + 'ttls', + [ + AuthenticationParameter( + 'eap', + 'Authentication', + SETTING_TYPE_LIST, + [['Tunnelled TLS', 'ttls']] + ), + AuthenticationParameter( + 'anonymous-identity', + 'Anonymous identity', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'ca-cert', + 'CA certificate', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'phase2-auth', + 'Inner Authentication', + SETTING_TYPE_STRING, + [['PAP', 'pap'], + ['MSCHAP', 'mschap'], + ['MSCHAPv2', 'mschapv2'], + ['CHAP', 'chap']] + ), + AuthenticationParameter( + 'identity', + 'Username', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'password', + 'Password', + SETTING_TYPE_STRING, + [] + ) + ] + ), + AuthenticationType('Protected EAP (PEAP)', + 'peap', + [ + AuthenticationParameter( + 'eap', + 'Authentication', + SETTING_TYPE_LIST, + [['Protected EAP (PEAP)', 'peap']] + ), + AuthenticationParameter( + 'anonymous-identity', + 'Anonymous identity', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'ca-cert', + 'CA certificate', + SETTING_TYPE_CHOOSER, + [] + ), + AuthenticationParameter( + 'phase1-peapver', + 'PEAP version', + SETTING_TYPE_STRING, + [['Automatic', ''], + ['Version 0', '0'], + ['Version 1', '1']] + ), + AuthenticationParameter( + 'phase2-auth', + 'Inner Authentication', + SETTING_TYPE_STRING, + [['MSCHAPv2', 'mschapv2'], + ['MD5', 'md5'], + ['GTC', 'gtc']] + ), + AuthenticationParameter( + 'identity', + 'Username', + SETTING_TYPE_STRING, + [] + ), + AuthenticationParameter( + 'password', + 'Password', + SETTING_TYPE_STRING, + [] + ) + ] + ) + ] + class WirelessNetworkView(EventPulsingIcon): def __init__(self, initial_ap): @@ -312,7 +499,7 @@ class WirelessNetworkView(EventPulsingIcon): group = self._add_ciphers_from_flags(self._rsn_flags, False) wireless_security = WirelessSecurity() wireless_security.key_mgmt = 'wpa-psk' - wireless_security.proto = 'rsn' + wireless_security.proto = ['rsn'] wireless_security.pairwise = pairwise wireless_security.group = group return wireless_security @@ -324,7 +511,31 @@ class WirelessNetworkView(EventPulsingIcon): group = self._add_ciphers_from_flags(self._wpa_flags, False) wireless_security = WirelessSecurity() wireless_security.key_mgmt = 'wpa-psk' - wireless_security.proto = 'wpa' + wireless_security.proto = ['wpa'] + wireless_security.pairwise = pairwise + wireless_security.group = group + return wireless_security + + if (self._rsn_flags & network.NM_802_11_AP_SEC_KEY_MGMT_802_1X) and \ + (self._device_caps & network.NM_WIFI_DEVICE_CAP_RSN): + # WPA2 Enterprise + pairwise = self._add_ciphers_from_flags(self._rsn_flags, True) + group = self._add_ciphers_from_flags(self._rsn_flags, False) + wireless_security = WirelessSecurity() + wireless_security.key_mgmt = 'wpa-eap' + wireless_security.proto = ['rsn'] + wireless_security.pairwise = pairwise + wireless_security.group = group + return wireless_security + + if (self._wpa_flags & network.NM_802_11_AP_SEC_KEY_MGMT_802_1X) and \ + (self._device_caps & network.NM_WIFI_DEVICE_CAP_WPA): + # WPA Enterprise + pairwise = self._add_ciphers_from_flags(self._wpa_flags, True) + group = self._add_ciphers_from_flags(self._wpa_flags, False) + wireless_security = WirelessSecurity() + wireless_security.key_mgmt = 'wpa-eap' + wireless_security.proto = ['wpa'] wireless_security.pairwise = pairwise wireless_security.group = group return wireless_security @@ -359,8 +570,9 @@ class WirelessNetworkView(EventPulsingIcon): elif self._mode == network.NM_802_11_MODE_ADHOC: settings.wireless.mode = 'adhoc' settings.wireless.band = 'bg' - settings.ip4_config = IP4Config() - settings.ip4_config.method = 'link-local' + if network.is_sugar_adhoc_network(self._name): + settings.ip4_config = IP4Config() + settings.ip4_config.method = 'link-local' wireless_security = self._get_security() settings.wireless_security = wireless_security @@ -368,6 +580,34 @@ class WirelessNetworkView(EventPulsingIcon): if wireless_security is not None: settings.wireless.security = '802-11-wireless-security' + # Take in the settings, if applicable. + if (wireless_security is not None) and \ + ( (wireless_security.key_mgmt == 'ieee8021x') or \ + (wireless_security.key_mgmt == 'wpa-eap') ): + keydialog.get_key_values(AUTHENTICATION_LIST, + self.__add_and_activate_connection, + settings) + else: + self.__add_and_activate_connection(settings) + + def __add_and_activate_connection(self, settings, additional_settings=None): + if additional_settings is not None: + key_value_dict = {} + auth_params_list = additional_settings._params_list + + for auth_param in auth_params_list: + key = auth_param._key_name + value = auth_param._value + logging.debug('key == %s', key) + logging.debug('value == %s', value) + if len(value) > 0: + key_value_dict[key] = value + else: + logging.debug('Not setting empty value for key :' + ' %s', key) + + settings.wpa_eap_setting = key_value_dict + network.add_and_activate_connection(self._device, settings, self.get_first_ap().model) diff --git a/src/jarabe/frame/__init__.py b/src/jarabe/frame/__init__.py index b3e4b80..8732b96 100644 --- a/src/jarabe/frame/__init__.py +++ b/src/jarabe/frame/__init__.py @@ -14,13 +14,13 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from jarabe.frame.frame import Frame - _view = None def get_view(): + from jarabe.frame.frame import Frame + global _view if not _view: _view = Frame() diff --git a/src/jarabe/frame/activitiestray.py b/src/jarabe/frame/activitiestray.py index 38fde7b..ef8435f 100644 --- a/src/jarabe/frame/activitiestray.py +++ b/src/jarabe/frame/activitiestray.py @@ -246,6 +246,24 @@ class ActivitiesTray(HTray): button.connect('clicked', self.__activity_clicked_cb, home_activity) button.show() + # JournalActivity is always the first activity to be added. + # Broadcast the signal-of-its-creation. + if group is None: + self._signal_addition_of_journal_activity() + + def _signal_addition_of_journal_activity(self): + monitor_file = os.path.expanduser('~/.sugar/journal_created') + + # Remove the file, if it exists. + # This is important, since we are checking for the + # FILE_CREATED event in the monitor. + if os.path.exists(monitor_file): + os.remove(monitor_file) + + # Now, create the file. + f = open(monitor_file, 'w') + f.close() + def __activity_removed_cb(self, home_model, home_activity): logging.debug('__activity_removed_cb: %r', home_activity) button = self._buttons[home_activity] diff --git a/src/jarabe/frame/frame.py b/src/jarabe/frame/frame.py index 410e08b..659df19 100644 --- a/src/jarabe/frame/frame.py +++ b/src/jarabe/frame/frame.py @@ -15,6 +15,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA import logging +import os from gi.repository import Gtk from gi.repository import Gdk @@ -33,6 +34,7 @@ from jarabe.frame.devicestray import DevicesTray from jarabe.frame.framewindow import FrameWindow from jarabe.frame.clipboardpanelwindow import ClipboardPanelWindow from jarabe.frame.notification import NotificationIcon, NotificationWindow +from jarabe.frame.notification import NotificationButton, HistoryPalette from jarabe.model import notifications @@ -43,6 +45,8 @@ BOTTOM_LEFT = 3 _NOTIFICATION_DURATION = 5000 +_DEFAULT_ICON = 'emblem-notification' + class _Animation(animator.Animation): def __init__(self, frame, end): @@ -83,6 +87,10 @@ class Frame(object): self._event_area.connect('enter', self._enter_corner_cb) self._event_area.show() + self._activities_tray = None + self._devices_tray = None + self._friends_tray = None + self._top_panel = self._create_top_panel() self._bottom_panel = self._create_bottom_panel() self._left_panel = self._create_left_panel() @@ -94,6 +102,7 @@ class Frame(object): self._key_listener = _KeyListener(self) self._notif_by_icon = {} + self._notif_by_message = {} notification_service = notifications.get_service() notification_service.notification_received.connect( @@ -143,6 +152,8 @@ class Frame(object): panel.append(activities_tray) activities_tray.show() + self._activities_tray = activities_tray + return panel def _create_bottom_panel(self): @@ -152,6 +163,8 @@ class Frame(object): panel.append(devices_tray) devices_tray.show() + self._devices_tray = devices_tray + return panel def _create_right_panel(self): @@ -161,6 +174,8 @@ class Frame(object): panel.append(tray) tray.show() + self._friends_tray = tray + return panel def _create_left_panel(self): @@ -211,15 +226,7 @@ class Frame(object): else: self.show() - def notify_key_press(self): - self._key_listener.key_press() - - def add_notification(self, icon, corner=Gtk.CornerType.TOP_LEFT, - duration=_NOTIFICATION_DURATION): - - if not isinstance(icon, NotificationIcon): - raise TypeError('icon must be a NotificationIcon.') - + def _create_notification_window(self, corner): window = NotificationWindow() screen = Gdk.Screen.get_default() @@ -235,6 +242,47 @@ class Frame(object): else: raise ValueError('Inalid corner: %r' % corner) + return window + + def _add_message_button(self, button, corner): + if corner == Gtk.CornerType.BOTTOM_RIGHT: + self._devices_tray.add_item(button) + elif corner == Gtk.CornerType.TOP_RIGHT: + self._friends_tray.add_item(button) + else: + self._activities_tray.add_item(button) + + def _remove_message_button(self, button, corner): + if corner == Gtk.CornerType.BOTTOM_RIGHT: + self._devices_tray.remove_item(button) + elif corner == Gtk.CornerType.TOP_RIGHT: + self._friends_tray.remove_item(button) + else: + self._activities_tray.remove_item(button) + + def _launch_notification_icon(self, icon_name, xo_color, + position, duration): + icon = NotificationIcon() + icon.props.xo_color = xo_color + + if icon_name.startswith(os.sep): + icon.props.icon_filename = icon_name + else: + icon.props.icon_name = icon_name + + self.add_notification(icon, position, duration) + + def notify_key_press(self): + self._key_listener.key_press() + + def add_notification(self, icon, corner=Gtk.CornerType.TOP_LEFT, + duration=_NOTIFICATION_DURATION): + + if not isinstance(icon, NotificationIcon): + raise TypeError('icon must be a NotificationIcon.') + + window = self._create_notification_window(corner) + window.add(icon) icon.show() window.show() @@ -253,28 +301,77 @@ class Frame(object): window.destroy() del self._notif_by_icon[icon] + def add_message(self, body, summary='', icon_name=_DEFAULT_ICON, + xo_color=None, corner=Gtk.CornerType.TOP_LEFT, + duration=_NOTIFICATION_DURATION): + + if xo_color is None: + xo_color = profile.get_color() + + button = self._notif_by_message.get(corner, None) + if button is None: + button = NotificationButton(_DEFAULT_ICON, xo_color) + button.show() + self._add_message_button(button, corner) + self._notif_by_message[corner] = button + + palette = button.get_palette() + if palette is None: + palette = HistoryPalette() + palette.set_group_id('frame') + palette.connect('clear-messages', self.remove_message, corner) + palette.connect('notice-messages', button.stop_pulsing) + button.set_palette(palette) + + button.start_pulsing() + + palette.push_message(body, summary, icon_name, xo_color) + if not self.visible: + self._launch_notification_icon(_DEFAULT_ICON, xo_color, corner, duration) + + def remove_message(self, palette, corner): + if corner not in self._notif_by_message: + logging.debug('Button %s is not active', str(corner)) + return + + button = self._notif_by_message[corner] + self._remove_message_button(button, corner) + del self._notif_by_message[corner] + def __notification_received_cb(self, **kwargs): - logging.debug('__notification_received_cb') - icon = NotificationIcon() + logging.debug('__notification_received_cb %r', kwargs) hints = kwargs['hints'] - icon_file_name = hints.get('x-sugar-icon-file-name', '') - if icon_file_name: - icon.props.icon_filename = icon_file_name - else: - icon.props.icon_name = 'application-octet-stream' + icon_name = hints.get('x-sugar-icon-file-name', '') + if not icon_name: + icon_name = _DEFAULT_ICON icon_colors = hints.get('x-sugar-icon-colors', '') if not icon_colors: icon_colors = profile.get_color() - icon.props.xo_color = icon_colors duration = kwargs.get('expire_timeout', -1) if duration == -1: duration = _NOTIFICATION_DURATION - self.add_notification(icon, Gtk.CornerType.TOP_RIGHT, duration) + category = hints.get('category', '') + if category == 'device': + position = Gtk.CornerType.BOTTOM_RIGHT + elif category == 'presence': + position = Gtk.CornerType.TOP_RIGHT + else: + position = Gtk.CornerType.TOP_LEFT + + summary = kwargs.get('summary', '') + body = kwargs.get('body', '') + + if summary or body: + self.add_message(body, summary, icon_name, + icon_colors, position, duration) + else: + self._launch_notification_icon(icon_name, icon_colors, + position, duration) def __notification_cancelled_cb(self, **kwargs): # Do nothing for now. Our notification UI is so simple, there's no diff --git a/src/jarabe/frame/notification.py b/src/jarabe/frame/notification.py index 184a779..3adaed1 100644 --- a/src/jarabe/frame/notification.py +++ b/src/jarabe/frame/notification.py @@ -18,11 +18,197 @@ from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk +import re +import os + +from gettext import gettext as _ + from sugar3.graphics import style from sugar3.graphics.xocolor import XoColor +from sugar3.graphics.palette import Palette +from sugar3.graphics.palettemenuitem import PaletteMenuItem +from sugar3.graphics.toolbutton import ToolButton +from sugar3 import profile + +from jarabe.frame.frameinvoker import FrameWidgetInvoker from jarabe.view.pulsingicon import PulsingIcon +_PULSE_TIMEOUT = 3 +_PULSE_COLOR = XoColor('%s,%s' % \ + (style.COLOR_BUTTON_GREY.get_svg(), style.COLOR_TRANSPARENT.get_svg())) +_BODY_FILTERS = "" + + +def _create_pulsing_icon(icon_name, xo_color, timeout=None): + icon = PulsingIcon( + pixel_size=style.STANDARD_ICON_SIZE, + pulse_color=_PULSE_COLOR, + base_color=xo_color + ) + + if timeout is not None: + icon.timeout = timeout + + if icon_name.startswith(os.sep): + icon.props.file = icon_name + else: + icon.props.icon_name = icon_name + + return icon + + +class _HistoryIconWidget(Gtk.Alignment): + __gtype_name__ = 'SugarHistoryIconWidget' + + def __init__(self, icon_name, xo_color): + icon = _create_pulsing_icon(icon_name, xo_color, _PULSE_TIMEOUT) + icon.props.pulsing = True + + Gtk.Alignment.__init__(self, xalign=0.5, yalign=0.0) + self.props.top_padding = style.DEFAULT_PADDING + self.set_size_request( + style.GRID_CELL_SIZE - style.FOCUS_LINE_WIDTH * 2, + style.GRID_CELL_SIZE - style.DEFAULT_PADDING) + self.add(icon) + + +class _HistorySummaryWidget(Gtk.Alignment): + __gtype_name__ = 'SugarHistorySummaryWidget' + + def __init__(self, summary): + summary_label = Gtk.Label() + summary_label.props.wrap = True + summary_label.set_markup( + '%s' % GObject.markup_escape_text(summary)) + + Gtk.Alignment.__init__(self, xalign=0.0, yalign=1.0) + self.props.right_padding = style.DEFAULT_SPACING + self.add(summary_label) + + +class _HistoryBodyWidget(Gtk.Alignment): + __gtype_name__ = 'SugarHistoryBodyWidget' + def __init__(self, body): + body_label = Gtk.Label() + body_label.props.wrap = True + body_label.set_markup(body) + + Gtk.Alignment.__init__(self, xalign=0, yalign=0.0) + self.props.right_padding = style.DEFAULT_SPACING + self.add(body_label) + + +class _MessagesHistoryBox(Gtk.VBox): + __gtype_name__ = 'SugarMessagesHistoryBox' + + def __init__(self): + Gtk.VBox.__init__(self) + self._setup_links_style() + + def _setup_links_style(self): + # XXX: find a better way to change style for upstream + link_color = profile.get_color().get_fill_color() + visited_link_color = profile.get_color().get_stroke_color() + + links_style=''' + style "label" { + GtkLabel::link-color="%s" + GtkLabel::visited-link-color="%s" + } + widget_class "*GtkLabel" style "label" + ''' % (link_color, visited_link_color) + Gtk.rc_parse_string(links_style) + + def push_message(self, body, summary, icon_name, xo_color): + entry = Gtk.HBox() + + icon_widget = _HistoryIconWidget(icon_name, xo_color) + entry.pack_start(icon_widget, False, False, 0) + + message = Gtk.VBox() + message.props.border_width = style.DEFAULT_PADDING + entry.pack_start(message, True, True, 0) + + if summary: + summary_widget = _HistorySummaryWidget(summary) + message.pack_start(summary_widget, False, False, 0) + + body = re.sub(_BODY_FILTERS, '', body) + + if body: + body_widget = _HistoryBodyWidget(body) + message.pack_start(body_widget, True, True, 0) + + entry.show_all() + self.pack_start(entry, True, True, 0) + self.reorder_child(entry, 0) + + self_width_ = self.props.width_request + self_height = self.props.height_request + if (self_height > Gdk.Screen.height() / 4 * 3) and \ + (len(self.get_children()) > 1): + self.remove(self.get_children()[-1]) + +class HistoryPalette(Palette): + __gtype_name__ = 'SugarHistoryPalette' + + __gsignals__ = { + 'clear-messages': (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'notice-messages': (GObject.SignalFlags.RUN_FIRST, None, ([])) + } + + def __init__(self): + Palette.__init__(self) + + self._update_accept_focus() + + self._messages_box = _MessagesHistoryBox() + self._messages_box.show() + + palette_box = self._palette_box + primary_box = self._primary_box + primary_box.hide() + palette_box.add(self._messages_box) + palette_box.reorder_child(self._messages_box, 0) + + clear_option = PaletteMenuItem(_('Clear history'), 'dialog-cancel') + clear_option.connect('activate', self.__clear_messages_cb) + clear_option.show() + + vbox = Gtk.VBox() + self.set_content(vbox) + vbox.show() + + vbox.add(clear_option) + + self.connect('popup', self.__notice_messages_cb) + + def __clear_messages_cb(self, clear_option): + self.emit('clear-messages') + + def __notice_messages_cb(self, palette): + self.emit('notice-messages') + + def push_message(self, body, summary, icon_name, xo_color): + self._messages_box.push_message(body, summary, icon_name, xo_color) + + +class NotificationButton(ToolButton): + + def __init__(self, icon_name, xo_color): + ToolButton.__init__(self) + self._icon = _create_pulsing_icon(icon_name, xo_color) + self.set_icon_widget(self._icon) + self._icon.show() + self.set_palette_invoker(FrameWidgetInvoker(self)) + + def start_pulsing(self): + self._icon.props.pulsing = True + + def stop_pulsing(self, widget): + self._icon.props.pulsing = False + class NotificationIcon(Gtk.EventBox): __gtype_name__ = 'SugarNotificationIcon' @@ -33,28 +219,29 @@ class NotificationIcon(Gtk.EventBox): 'icon-filename': (str, None, None, None, GObject.PARAM_READWRITE), } - _PULSE_TIMEOUT = 3 - def __init__(self, **kwargs): self._icon = PulsingIcon(pixel_size=style.STANDARD_ICON_SIZE) Gtk.EventBox.__init__(self, **kwargs) self.props.visible_window = False + self.set_app_paintable(True) - self._icon.props.pulse_color = \ - XoColor('%s,%s' % (style.COLOR_BUTTON_GREY.get_svg(), - style.COLOR_TRANSPARENT.get_svg())) - self._icon.props.pulsing = True + color = Gdk.color_parse(style.COLOR_BLACK.get_html()) + self.modify_bg(Gtk.StateType.PRELIGHT, color) + + color = Gdk.color_parse(style.COLOR_BUTTON_GREY.get_html()) + self.modify_bg(Gtk.StateType.ACTIVE, color) + + self._icon.props.pulse_color = _PULSE_COLOR + self._icon.props.timeout = _PULSE_TIMEOUT self.add(self._icon) self._icon.show() - GObject.timeout_add_seconds(self._PULSE_TIMEOUT, - self.__stop_pulsing_cb) + self.start_pulsing() self.set_size_request(style.GRID_CELL_SIZE, style.GRID_CELL_SIZE) - def __stop_pulsing_cb(self): - self._icon.props.pulsing = False - return False + def start_pulsing(self): + self._icon.props.pulsing = True def do_set_property(self, pspec, value): if pspec.name == 'xo-color': @@ -87,17 +274,13 @@ class NotificationIcon(Gtk.EventBox): class NotificationWindow(Gtk.Window): __gtype_name__ = 'SugarNotificationWindow' - def __init__(self, **kwargs): - - Gtk.Window.__init__(self, **kwargs) + def __init__(self): + Gtk.Window.__init__(self) self.set_decorated(False) self.set_resizable(False) self.connect('realize', self._realize_cb) def _realize_cb(self, widget): - self.set_type_hint(Gdk.WindowTypeHint.DIALOG) - self.window.set_accept_focus(False) - color = Gdk.color_parse(style.COLOR_TOOLBAR_GREY.get_html()) self.modify_bg(Gtk.StateType.NORMAL, color) diff --git a/src/jarabe/intro/window.py b/src/jarabe/intro/window.py index 252870d..469514e 100644 --- a/src/jarabe/intro/window.py +++ b/src/jarabe/intro/window.py @@ -34,12 +34,30 @@ from sugar3.graphics.xocolor import XoColor from jarabe.intro import colorpicker -def create_profile(name, color=None): +def create_profile(name, age, color=None): if not color: color = XoColor() client = GConf.Client.get_default() client.set_string('/desktop/sugar/user/nick', name) + + + # Algorithm to generate the timestamp of the birthday of the + # XO-user :: + # + # timestamp = current_timestamp - [age * (365 * 24 * 60 * 60)] + # + # Note that, this timestamp may actually (in worst-case) be + # off-target by 1 year, but that is ok, since we want an + # "approximate" age of the XO-user (for statistics-collection). + import time + current_timestamp = time.time() + xo_user_age_as_timestamp = int(age) * 365 * 24 * 60 * 60 + + approx_timestamp_at_user_birthday = current_timestamp - xo_user_age_as_timestamp + client.set_int('/desktop/sugar/user/birth_timestamp', int(approx_timestamp_at_user_birthday)) + # Done. + client.set_string('/desktop/sugar/user/color', color.to_string()) client.suggest_sync() @@ -125,6 +143,46 @@ class _NamePage(_Page): self._entry.grab_focus() +class _AgePage(_Page): + def __init__(self, intro): + _Page.__init__(self) + self._intro = intro + self._max_age = 1000 + + alignment = Gtk.Alignment.new(0.5, 0.5, 0, 0) + self.pack_start(alignment, expand=True, fill=True, padding=0) + + hbox = Gtk.HBox(spacing=style.DEFAULT_SPACING) + alignment.add(hbox) + + label = Gtk.Label(_('Age:')) + hbox.pack_start(label, False, True, 0) + + adjustment = Gtk.Adjustment(0, 0, self._max_age, 1, 0, 0) + self._entry = Gtk.SpinButton(adjustment=adjustment) + self._entry.props.editable = True + self._entry.connect('notify::text', self._text_changed_cb) + self._entry.set_max_length(15) + hbox.pack_start(self._entry, False, True, 0) + + label = Gtk.Label(_('years')) + hbox.pack_start(label, False, True, 0) + + + def _text_changed_cb(self, entry, pspec): + valid = False + if entry.props.text.isdigit(): + int_value = int(entry.props.text) + valid = ((int_value > 0) and (int_value <= self._max_age)) + self.set_valid(valid) + + def get_age(self): + return int(self._entry.props.text) + + def activate(self): + self._entry.grab_focus() + + class _ColorPage(_Page): def __init__(self): _Page.__init__(self) @@ -148,11 +206,12 @@ class _ColorPage(_Page): class _IntroBox(Gtk.VBox): __gsignals__ = { 'done': (GObject.SignalFlags.RUN_FIRST, None, - ([GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT])), + ([GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT, GObject.TYPE_PYOBJECT])), } PAGE_NAME = 0 - PAGE_COLOR = 1 + PAGE_AGE = 1 + PAGE_COLOR = 2 PAGE_FIRST = PAGE_NAME PAGE_LAST = PAGE_COLOR @@ -163,6 +222,7 @@ class _IntroBox(Gtk.VBox): self._page = self.PAGE_NAME self._name_page = _NamePage(self) + self._age_page = _AgePage(self) self._color_page = _ColorPage() self._current_page = None self._next_button = None @@ -185,6 +245,8 @@ class _IntroBox(Gtk.VBox): if self._page == self.PAGE_NAME: self._current_page = self._name_page + if self._page == self.PAGE_AGE: + self._current_page = self._age_page elif self._page == self.PAGE_COLOR: self._current_page = self._color_page @@ -253,9 +315,10 @@ class _IntroBox(Gtk.VBox): def done(self): name = self._name_page.get_name() + age = self._age_page.get_age() color = self._color_page.get_color() - self.emit('done', name, color) + self.emit('done', name, age, color) class IntroWindow(Gtk.Window): @@ -274,12 +337,12 @@ class IntroWindow(Gtk.Window): self._intro_box.show() self.connect('key-press-event', self.__key_press_cb) - def _done_cb(self, box, name, color): + def _done_cb(self, box, name, age, color): self.hide() - GObject.idle_add(self._create_profile_cb, name, color) + GObject.idle_add(self._create_profile_cb, name, age, color) - def _create_profile_cb(self, name, color): - create_profile(name, color) + def _create_profile_cb(self, name, age, color): + create_profile(name, age, color) Gtk.main_quit() return False diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am index ba29062..df8f961 100644 --- a/src/jarabe/journal/Makefile.am +++ b/src/jarabe/journal/Makefile.am @@ -15,4 +15,6 @@ sugar_PYTHON = \ model.py \ objectchooser.py \ palettes.py \ - volumestoolbar.py + processdialog.py \ + volumestoolbar.py \ + webdavmanager.py diff --git a/src/jarabe/journal/expandedentry.py b/src/jarabe/journal/expandedentry.py index 21c0672..0170d0b 100644 --- a/src/jarabe/journal/expandedentry.py +++ b/src/jarabe/journal/expandedentry.py @@ -160,11 +160,30 @@ class ExpandedEntry(Gtk.EventBox): self._buddy_list.pack_start(self._create_buddy_list(), False, False, style.DEFAULT_SPACING) - description = metadata.get('description', '') + # TRANS: Do not translate the """%s""". + uploader_nick_text = self.__create_text_description( + _('Source XO Nick :: \n%s'), metadata.get('uploader-nick', '')) + + # TRANS: Do not translate the """%s""". + uploader_serial_text = self.__create_text_description( + _('Source XO Serial Number :: \n%s'), metadata.get('uploader-serial', '')) + + # TRANS: Do not translate the """%s""". + misc_info_text = self.__create_text_description( + _('Misellaneous Information :: \n%s'), metadata.get('description', '')) + + description = uploader_nick_text + uploader_serial_text + misc_info_text self._description.get_buffer().set_text(description) + tags = metadata.get('tags', '') self._tags.get_buffer().set_text(tags) + def __create_text_description(self, heading, value): + if (value == '') or (value is None): + return '' + + return ((heading % value) + '\n\n') + def _create_keep_icon(self): keep_icon = KeepIcon() keep_icon.connect('toggled', self._keep_icon_toggled_cb) @@ -396,18 +415,20 @@ class ExpandedEntry(Gtk.EventBox): needs_update = True if needs_update: - if self._metadata.get('mountpoint', '/') == '/': - model.write(self._metadata, update_mtime=False) - else: - old_file_path = os.path.join(self._metadata['mountpoint'], - model.get_file_name(old_title, - self._metadata['mime_type'])) - model.write(self._metadata, file_path=old_file_path, - update_mtime=False) + from jarabe.journal.journalactivity import get_journal + self._metadata['mountpoint'] = \ + get_journal().get_detail_toolbox().get_mount_point() + + model.update_only_metadata_and_preview_files_and_return_file_paths(self._metadata) self._update_title_sid = None def _keep_icon_toggled_cb(self, keep_icon): + # If it is a locally-mounted remote-share, return without doing + # any processing. + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if keep_icon.get_active(): self._metadata['keep'] = 1 else: diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py index 4bb68fd..b2ffae0 100644 --- a/src/jarabe/journal/journalactivity.py +++ b/src/jarabe/journal/journalactivity.py @@ -19,6 +19,7 @@ import logging from gettext import gettext as _ import uuid +from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GdkX11 @@ -27,7 +28,8 @@ import statvfs import os from sugar3.graphics.window import Window -from sugar3.graphics.alert import ErrorAlert +from sugar3.graphics.icon import Icon +from sugar3.graphics.alert import Alert, ErrorAlert, ConfirmationAlert from sugar3.bundle.bundle import ZipExtractException, RegistrationException from sugar3 import env @@ -37,7 +39,9 @@ from gi.repository import SugarExt from jarabe.model import bundleregistry from jarabe.journal.journaltoolbox import MainToolbox, DetailToolbox +from jarabe.journal.journaltoolbox import EditToolbox from jarabe.journal.listview import ListView +from jarabe.journal.listmodel import ListModel from jarabe.journal.detailview import DetailView from jarabe.journal.volumestoolbar import VolumesToolbar from jarabe.journal import misc @@ -46,6 +50,7 @@ from jarabe.journal.objectchooser import ObjectChooser from jarabe.journal.modalalert import ModalAlert from jarabe.journal import model from jarabe.journal.journalwindow import JournalWindow +from jarabe.journal.journalwindow import show_normal_cursor J_DBUS_SERVICE = 'org.laptop.Journal' @@ -56,6 +61,7 @@ _SPACE_TRESHOLD = 52428800 _BUNDLE_ID = 'org.laptop.JournalActivity' _journal = None +_mount_point = None class JournalActivityDBusService(dbus.service.Object): @@ -124,8 +130,33 @@ class JournalActivity(JournalWindow): self._list_view = None self._detail_view = None self._main_toolbox = None + self._edit_toolbox = None self._detail_toolbox = None self._volumes_toolbar = None + self._editing_mode = False + self._alert = Alert() + + self._error_alert = Alert() + icon = Icon(icon_name='dialog-ok') + self._error_alert.add_button(Gtk.ResponseType.OK, _('Ok'), icon) + icon.show() + + self._confirmation_alert = Alert() + icon = Icon(icon_name='dialog-cancel') + self._confirmation_alert.add_button(Gtk.ResponseType.CANCEL, _('Stop'), icon) + icon.show() + icon = Icon(icon_name='dialog-ok') + self._confirmation_alert.add_button(Gtk.ResponseType.OK, _('Continue'), icon) + icon.show() + + self._current_alert = None + self.setup_handlers_for_alert_actions() + + self._info_alert = None + self._selected_entries = [] + self._bundle_installation_allowed = True + + set_mount_point('/') self._setup_main_view() self._setup_secondary_view() @@ -151,10 +182,17 @@ class JournalActivity(JournalWindow): self._check_available_space() def __volume_error_cb(self, gobject, message, severity): - alert = ErrorAlert(title=severity, msg=message) - alert.connect('response', self.__alert_response_cb) - self.add_alert(alert) - alert.show() + self.update_title_and_message(self._error_alert, severity, + message) + self._callback = None + self._data = None + self.update_alert(self._error_alert) + + def _show_alert(self, message, severity): + self.__volume_error_cb(None, message, severity) + + def _volume_error_cb(self, gobject, message, severity): + self.update_error_alert(severity, message, None, None) def __alert_response_cb(self, alert, response_id): self.remove_alert(alert) @@ -196,11 +234,14 @@ class JournalActivity(JournalWindow): self._main_toolbox.search_entry.connect('icon-press', self.__search_icon_pressed_cb) self._main_toolbox.set_mount_point('/') + #search_toolbar.set_mount_point('/') + set_mount_point('/') def _setup_secondary_view(self): self._secondary_view = Gtk.VBox() self._detail_toolbox = DetailToolbox() + self._detail_toolbox.set_mount_point('/') self._detail_toolbox.connect('volume-error', self.__volume_error_cb) @@ -240,9 +281,16 @@ class JournalActivity(JournalWindow): self.connect('key-press-event', self._key_press_event_cb) def show_main_view(self): - if self.toolbar_box != self._main_toolbox: - self.set_toolbar_box(self._main_toolbox) - self._main_toolbox.show() + if self._editing_mode: + self._toolbox = EditToolbox() + + # TRANS: Do not translate the "%d" + self._toolbox.set_total_number_of_entries(self.get_total_number_of_entries()) + else: + self._toolbox = self._main_toolbox + + self.set_toolbar_box(self._toolbox) + self._toolbox.show() if self.canvas != self._main_view: self.set_canvas(self._main_view) @@ -277,6 +325,10 @@ class JournalActivity(JournalWindow): def __volume_changed_cb(self, volume_toolbar, mount_point): logging.debug('Selected volume: %r.', mount_point) self._main_toolbox.set_mount_point(mount_point) + set_mount_point(mount_point) + + # Also, need to update the mount-point for Detail-View. + self._detail_toolbox.set_mount_point(mount_point) def __model_created_cb(self, sender, **kwargs): self._check_for_bundle(kwargs['object_id']) @@ -301,6 +353,9 @@ class JournalActivity(JournalWindow): self._list_view.update_dates() def _check_for_bundle(self, object_id): + if not self._bundle_installation_allowed: + return + registry = bundleregistry.get_registry() metadata = model.get(object_id) @@ -336,6 +391,9 @@ class JournalActivity(JournalWindow): metadata['bundle_id'] = bundle.get_bundle_id() model.write(metadata) + def set_bundle_installation_allowed(self, allowed): + self._bundle_installation_allowed = allowed + def __window_state_event_cb(self, window, event): logging.debug('window_state_event_cb %r', self) if event.changed_mask & Gdk.WindowState.ICONIFIED: @@ -378,6 +436,105 @@ class JournalActivity(JournalWindow): self.reveal() self.show_main_view() + def switch_to_editing_mode(self, switch): + # (re)-switch, only if not already. + if (switch) and (not self._editing_mode): + self._editing_mode = True + self.get_list_view().disable_drag_and_copy() + self.show_main_view() + elif (not switch) and (self._editing_mode): + self._editing_mode = False + self.get_list_view().enable_drag_and_copy() + self.show_main_view() + + def get_list_view(self): + return self._list_view + + def setup_handlers_for_alert_actions(self): + self._error_alert.connect('response', + self.__check_for_alert_action) + self._confirmation_alert.connect('response', + self.__check_for_alert_action) + + def __check_for_alert_action(self, alert, response_id): + self.hide_alert() + if self._callback is not None: + GObject.idle_add(self._callback, self._data, + response_id) + + def update_title_and_message(self, alert, title, message): + alert.props.title = title + alert.props.msg = message + + def update_alert(self, alert): + if self._current_alert is None: + self.add_alert(alert) + elif self._current_alert != alert: + self.remove_alert(self._current_alert) + self.add_alert(alert) + + self.remove_alert(self._current_alert) + self.add_alert(alert) + self._current_alert = alert + self._current_alert.show() + show_normal_cursor() + + def hide_alert(self): + if self._current_alert is not None: + self._current_alert.hide() + + def update_info_alert(self, title, message): + self.get_toolbar_box().display_running_status_in_multi_select(title, message) + + def update_error_alert(self, title, message, callback, data): + self.update_title_and_message(self._error_alert, title, + message) + self._callback = callback + self._data = data + self.update_alert(self._error_alert) + + def update_confirmation_alert(self, title, message, callback, + data): + self.update_title_and_message(self._confirmation_alert, title, + message) + self._callback = callback + self._data = data + self.update_alert(self._confirmation_alert) + + def update_progress(self, fraction): + self.get_toolbar_box().update_progress(fraction) + + def get_metadata_list(self, selected_state): + metadata_list = [] + + list_view_model = self.get_list_view().get_model() + for index in range(0, len(list_view_model)): + metadata = list_view_model.get_metadata(index) + metadata_selected = \ + list_view_model.get_selected_value(metadata['uid']) + + if ( (selected_state and metadata_selected) or \ + ((not selected_state) and (not metadata_selected)) ): + metadata_list.append(metadata) + + return metadata_list + + def get_total_number_of_entries(self): + list_view_model = self.get_list_view().get_model() + return len(list_view_model) + + def is_editing_mode_present(self): + return self._editing_mode + + def get_volumes_toolbar(self): + return self._volumes_toolbar + + def get_toolbar_box(self): + return self._toolbox + + def get_detail_toolbox(self): + return self._detail_toolbox + def get_journal(): global _journal @@ -389,3 +546,11 @@ def get_journal(): def start(): get_journal() + + +def set_mount_point(mount_point): + global _mount_point + _mount_point = mount_point + +def get_mount_point(): + return _mount_point diff --git a/src/jarabe/journal/journaltoolbox.py b/src/jarabe/journal/journaltoolbox.py index 09d8a31..cb19f65 100644 --- a/src/jarabe/journal/journaltoolbox.py +++ b/src/jarabe/journal/journaltoolbox.py @@ -16,6 +16,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA from gettext import gettext as _ +from gettext import ngettext import logging from datetime import datetime, timedelta import os @@ -26,6 +27,7 @@ from gi.repository import GObject from gi.repository import Gio import glib from gi.repository import Gtk +from gi.repository import Gdk from sugar3.graphics.palette import Palette from sugar3.graphics.toolbarbox import ToolbarBox @@ -45,8 +47,9 @@ from jarabe.journal import misc from jarabe.journal import model from jarabe.journal.palettes import ClipboardMenu from jarabe.journal.palettes import VolumeMenu -from jarabe.journal import journalwindow +from jarabe.journal import journalwindow, palettes +COPY_MENU_HELPER = palettes.get_copy_menu_helper() _AUTOSEARCH_TIMEOUT = 1000 @@ -74,6 +77,10 @@ class MainToolbox(ToolbarBox): def __init__(self): ToolbarBox.__init__(self) + self._info_widget = MultiSelectEntriesInfoWidget() + self.add(self._info_widget) + self._info_widget.hide() + self._mount_point = None self.search_entry = iconentry.IconEntry() @@ -123,6 +130,12 @@ class MainToolbox(ToolbarBox): self.refresh_filters() + def update_progress(self, fraction): + self._info_widget.update_progress(fraction) + + def hide_info_widget(self): + self._info_widget.hide() + def _get_when_search_combo(self): when_search = ComboBox() when_search.append_item(_ACTION_ANYTIME, _('Anytime')) @@ -390,11 +403,30 @@ class DetailToolbox(ToolbarBox): separator.show() erase_button = ToolButton('list-remove') + self._erase_button = erase_button erase_button.set_tooltip(_('Erase')) erase_button.connect('clicked', self._erase_button_clicked_cb) self.toolbar.insert(erase_button, -1) erase_button.show() + def set_mount_point(self, mount_point): + self._mount_point = mount_point + self.set_sensitivity_of_icons() + + def get_mount_point(self): + return self._mount_point + + def set_sensitivity_of_icons(self): + mount_point = self.get_mount_point() + if model.is_mount_point_for_locally_mounted_remote_share(mount_point): + sensitivity = False + else: + sensitivity = True + + self._resume.set_sensitive(sensitivity) + self._duplicate.set_sensitive(sensitivity) + self._erase_button.set_sensitive(sensitivity) + def set_metadata(self, metadata): self._metadata = metadata self._refresh_copy_palette() @@ -452,39 +484,11 @@ class DetailToolbox(ToolbarBox): palette.menu.remove(menu_item) menu_item.destroy() - clipboard_menu = ClipboardMenu(self._metadata) - clipboard_menu.set_image(Icon(icon_name='toolbar-edit', - icon_size=Gtk.IconSize.MENU)) - clipboard_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(clipboard_menu) - clipboard_menu.show() - - if self._metadata['mountpoint'] != '/': - client = GConf.Client.get_default() - color = XoColor(client.get_string('/desktop/sugar/user/color')) - journal_menu = VolumeMenu(self._metadata, _('Journal'), '/') - journal_menu.set_image(Icon(icon_name='activity-journal', - xo_color=color, - icon_size=Gtk.IconSize.MENU)) - journal_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(journal_menu) - journal_menu.show() - - volume_monitor = Gio.VolumeMonitor.get() - icon_theme = Gtk.IconTheme.get_default() - for mount in volume_monitor.get_mounts(): - if self._metadata['mountpoint'] == mount.get_root().get_path(): - continue - volume_menu = VolumeMenu(self._metadata, mount.get_name(), - mount.get_root().get_path()) - for name in mount.get_icon().props.names: - if icon_theme.has_icon(name): - volume_menu.set_image(Icon(icon_name=name, - icon_size=Gtk.IconSize.MENU)) - break - volume_menu.connect('volume-error', self.__volume_error_cb) - palette.menu.append(volume_menu) - volume_menu.show() + COPY_MENU_HELPER.insert_copy_to_menu_items(palette.menu, + [self._metadata], + show_editing_alert=False, + show_progress_info_alert=False, + batch_mode=False) def _refresh_duplicate_palette(self): color = misc.get_icon_color(self._metadata) @@ -524,6 +528,270 @@ class DetailToolbox(ToolbarBox): menu_item.show() +class EditToolbox(ToolbarBox): + def __init__(self): + ToolbarBox.__init__(self) + + self.toolbar.add(SelectNoneButton()) + self.toolbar.add(SelectAllButton()) + + self.toolbar.add(Gtk.SeparatorToolItem()) + + self.toolbar.add(BatchEraseButton()) + self.toolbar.add(BatchCopyButton()) + + self.toolbar.add(Gtk.SeparatorToolItem()) + + self._multi_select_info_widget = MultiSelectEntriesInfoWidget() + self.toolbar.add(self._multi_select_info_widget) + + self.show_all() + self.toolbar.show_all() + + def process_new_selected_entry_in_multi_select(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', True, True) + + def process_new_deselected_entry_in_multi_select(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', False, True) + + def display_running_status_in_multi_select(self, primary_info, + secondary_info): + GObject.idle_add(self._multi_select_info_widget.update_text, + primary_info, secondary_info, + None, None) + + def display_already_selected_entries_status(self): + GObject.idle_add(self._multi_select_info_widget.update_text, + '', '', True, False) + + def set_total_number_of_entries(self, total): + self._multi_select_info_widget.set_total_number_of_entries(total) + + def get_current_entry_number(self): + return self._multi_select_info_widget.get_current_entry_number() + + def update_progress(self, fraction): + self._multi_select_info_widget.update_progress(fraction) + + +class SelectNoneButton(ToolButton): + def __init__(self): + ToolButton.__init__(self, 'select-none') + self.props.tooltip = _('Deselect all') + + self.connect('clicked', self.__do_deselect_all) + + def __do_deselect_all(self, widget_clicked): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_list_view()._selected_entries = 0 + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + journal.get_list_view().refresh() + + +class SelectAllButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'select-all') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=False, + show_progress_info_alert=False, + batch_mode=True, + auto_deselect_source_entries=True, + need_to_popup_options=False, + operate_on_deselected_entries=True, + show_not_completed_ops_info=False) + self.props.tooltip = _('Select all') + + def _get_actionable_signal(self): + return 'clicked' + + def _get_editing_alert_operation(self): + return _('Select all') + + def _get_info_alert_title(self): + return _('Selecting') + + def _get_post_selection_alert_message_entries_len(self): + return self._model_len + + def _get_post_selection_alert_message(self, entries_len): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + return ngettext('You have selected %d entry.', + 'You have selected %d entries.', + entries_len) % (entries_len,) + + def _operate(self, metadata): + # Nothing specific needs to be done. + # The checkboxes are unchecked as part of the toggling of any + # operation that operates on selected entries. + + # This is sync-operation. Thus, call the callback. + self._post_operate_per_metadata_per_action(metadata) + + +class BatchEraseButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'edit-delete') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True, + auto_deselect_source_entries=True, + need_to_popup_options=False, + operate_on_deselected_entries=False, + show_not_completed_ops_info=True) + self.props.tooltip = _('Erase') + + # De-sensitize Batch-Erase button, for locally-mounted-remote-shares. + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + self.set_sensitive(False) + + def _get_actionable_signal(self): + return 'clicked' + + def _get_editing_alert_title(self): + return _('Erase') + + def _get_editing_alert_message(self, entries_len): + return ngettext('Do you want to erase %d entry?', + 'Do you want to erase %d entries?', + entries_len) % (entries_len) + + def _get_editing_alert_operation(self): + return _('Erase') + + def _get_info_alert_title(self): + return _('Erasing') + + def _operate(self, metadata): + model.delete(metadata['uid']) + + # This is sync-operation. Thus, call the callback. + self._post_operate_per_metadata_per_action(metadata) + + +class BatchCopyButton(ToolButton, palettes.ActionItem): + def __init__(self): + ToolButton.__init__(self, 'edit-copy') + palettes.ActionItem.__init__(self, '', [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True, + auto_deselect_source_entries=False, + need_to_popup_options=True, + operate_on_deselected_entries=False, + show_not_completed_ops_info=False) + + self.props.tooltip = _('Copy') + + self._metadata_list = None + self._fill_and_pop_up_options(None) + + def _get_actionable_signal(self): + return 'clicked' + + def _fill_and_pop_up_options(self, widget_clicked): + for child in self.props.palette.menu.get_children(): + self.props.palette.menu.remove(child) + + COPY_MENU_HELPER.insert_copy_to_menu_items(self.props.palette.menu, + [], + show_editing_alert=True, + show_progress_info_alert=True, + batch_mode=True) + if widget_clicked is not None: + self.props.palette.popup(immediate=True, state=1) + + +class MultiSelectEntriesInfoWidget(Gtk.ToolItem): + def __init__(self): + Gtk.ToolItem.__init__(self) + + self._box = Gtk.VBox() + self._selected_entries = 0 + + self._label = Gtk.Label() + self._box.pack_start(self._label, True, True, 0) + + self._progress_label = Gtk.Label() + self._box.pack_start(self._progress_label, True, True, 0) + + self.add(self._box) + + self.show_all() + self._box.show_all() + self._progress_label.hide() + + def set_total_number_of_entries(self, total): + self._total = total + + def update_progress(self, fraction): + percent = '%.02f' % (fraction * 100) + + # TRANS: Do not translate %.02f + text = '%.02f%% complete' % (fraction * 100) + if (str(percent) != '100.00') and (str(percent).endswith('00')): + self._progress_label.set_text(text) + self._progress_label.show() + self.show_all() + Gdk.Window.process_all_updates() + else: + self._progress_label.hide() + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + self.hide() + + def update_text(self, primary_text, secondary_text, special_action, + update_selected_entries): + # If "special_action" is None, + # we need to display the info, conveyed by + # "primary_message" and "secondary_message" + # + # If "special_action" is True, + # a new entry has been selected. + # + # If "special_action" is False, + # an enrty has been deselected. + if special_action == None: + self._label.set_text(primary_text + secondary_text) + self._label.show() + else: + if update_selected_entries: + if special_action == True: + self._selected_entries = self._selected_entries + 1 + elif special_action == False: + self._selected_entries = self._selected_entries - 1 + + # TRANS: Do not translate the two "%d". + message = _('Selected %d of %d') % (self._selected_entries, + self._total) + + # Only show the "selected x of y" for "Select All", or + # "Deselect All", or if the user checked/unchecked a + # checkbox. + from jarabe.journal.palettes import get_current_action_item + current_action_item = get_current_action_item() + if current_action_item == None or \ + isinstance(current_action_item, SelectAllButton) or \ + isinstance(current_action_item, SelectNoneButton): + self._label.set_text(message) + self._label.show() + + Gdk.Window.process_all_updates() + + def get_current_entry_number(self): + return self._selected_entries + + class SortingButton(ToolButton): __gtype_name__ = 'JournalSortingButton' diff --git a/src/jarabe/journal/journalwindow.py b/src/jarabe/journal/journalwindow.py index 776a495..8fcecaf 100644 --- a/src/jarabe/journal/journalwindow.py +++ b/src/jarabe/journal/journalwindow.py @@ -15,6 +15,8 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +from gi.repository import Gdk + from sugar3.graphics.window import Window _journal_window = None @@ -31,3 +33,46 @@ class JournalWindow(Window): def get_journal_window(): return _journal_window + + +def set_widgets_active_state(active_state): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_toolbar_box().set_sensitive(active_state) + journal.get_list_view().set_sensitive(active_state) + journal.get_volumes_toolbar().set_sensitive(active_state) + + +def show_waiting_cursor(): + # Only show waiting-cursor, if this is the batch-mode. + + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + _journal_window.get_root_window().set_cursor(Gdk.Cursor.new(Gdk.CursorType.WATCH)) + + +def freeze_ui(): + # Only freeze, if this is the batch-mode. + + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + show_waiting_cursor() + + set_widgets_active_state(False) + + +def show_normal_cursor(): + _journal_window.get_root_window().set_cursor(Gdk.Cursor.new(Gdk.CursorType.LEFT_PTR)) + + +def unfreeze_ui(): + # Unfreeze, irrespective of whether this is the batch mode. + + set_widgets_active_state(True) + + show_normal_cursor() diff --git a/src/jarabe/journal/keepicon.py b/src/jarabe/journal/keepicon.py index 16e3a57..9c7b7d5 100644 --- a/src/jarabe/journal/keepicon.py +++ b/src/jarabe/journal/keepicon.py @@ -22,6 +22,8 @@ from sugar3.graphics.icon import Icon from sugar3.graphics import style from sugar3.graphics.xocolor import XoColor +from jarabe.journal import model + class KeepIcon(Gtk.ToggleButton): def __init__(self): @@ -37,6 +39,9 @@ class KeepIcon(Gtk.ToggleButton): self.connect('enter-notify-event', self.__enter_notify_event_cb) def __toggled_cb(self, widget): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if self.get_active(): client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) @@ -47,9 +52,15 @@ class KeepIcon(Gtk.ToggleButton): self._icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() def __enter_notify_event_cb(self, icon, event): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if not self.get_active(): self._icon.props.fill_color = style.COLOR_BUTTON_GREY.get_svg() def __leave_notify_event_cb(self, icon, event): + if model.is_current_mount_point_for_remote_share(model.DETAIL_VIEW): + return + if not self.get_active(): self._icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() diff --git a/src/jarabe/journal/listmodel.py b/src/jarabe/journal/listmodel.py index b98d01c..a5bb7b0 100644 --- a/src/jarabe/journal/listmodel.py +++ b/src/jarabe/journal/listmodel.py @@ -54,6 +54,7 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): COLUMN_BUDDY_1 = 9 COLUMN_BUDDY_2 = 10 COLUMN_BUDDY_3 = 11 + COLUMN_SELECT = 12 _COLUMN_TYPES = { COLUMN_UID: str, @@ -68,6 +69,7 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): COLUMN_BUDDY_1: object, COLUMN_BUDDY_3: object, COLUMN_BUDDY_2: object, + COLUMN_SELECT: bool, } _PAGE_SIZE = 10 @@ -79,6 +81,8 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): self._cached_row = None self._result_set = model.find(query, ListModel._PAGE_SIZE) self._temp_drag_file_path = None + self._selected = {} + self._uid_metadata_assoc = {} # HACK: The view will tell us that it is resizing so the model can # avoid hitting D-Bus and disk. @@ -248,3 +252,22 @@ class ListModel(GObject.GObject, Gtk.TreeModel, Gtk.TreeDragSource): return True return False + + def update_uid_metadata_assoc(self, uid, metadata): + self._uid_metadata_assoc[uid] = metadata + + def set_selected_value(self, uid, value): + if value == False: + del self._selected[uid] + elif value == True: + self._selected[uid] = value + + def get_selected_value(self, uid): + if self._selected.has_key(uid): + return True + else: + return False + + def get_in_memory_metadata(self, path): + uid = self[path][ListModel.COLUMN_UID] + return self._uid_metadata_assoc[uid] diff --git a/src/jarabe/journal/listview.py b/src/jarabe/journal/listview.py index 5b2c5ab..35c8092 100644 --- a/src/jarabe/journal/listview.py +++ b/src/jarabe/journal/listview.py @@ -67,7 +67,8 @@ class BaseListView(Gtk.Bin): 'clear-clicked': (GObject.SignalFlags.RUN_FIRST, None, ([])), } - def __init__(self): + def __init__(self, is_object_chooser): + self._is_object_chooser = is_object_chooser self._query = {} self._model = None self._progress_bar = None @@ -100,11 +101,11 @@ class BaseListView(Gtk.Bin): self._title_column = None self.sort_column = None self._add_columns() + self._inhibit_refresh = False + self._selected_entries = 0 + + self.enable_drag_and_copy() - self.tree_view.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, - [('text/uri-list', 0, 0), - ('journal-object-id', 0, 0)], - Gdk.DragAction.COPY) # Auto-update stuff self._fully_obscured = True @@ -116,6 +117,15 @@ class BaseListView(Gtk.Bin): model.updated.connect(self.__model_updated_cb) model.deleted.connect(self.__model_deleted_cb) + def enable_drag_and_copy(self): + self.tree_view.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, + [('text/uri-list', 0, 0), + ('journal-object-id', 0, 0)], + Gdk.DragAction.COPY) + + def disable_drag_and_copy(self): + self.tree_view.unset_rows_drag_source() + def __model_created_cb(self, sender, signal, object_id): if self._is_new_item_visible(object_id): self._set_dirty() @@ -136,6 +146,17 @@ class BaseListView(Gtk.Bin): return object_id.startswith(self._query['mountpoints'][0]) def _add_columns(self): + if not self._is_object_chooser: + cell_select = CellRendererToggle(self.tree_view) + cell_select.connect('clicked', self.__cell_select_clicked_cb) + + column = Gtk.TreeViewColumn() + column.props.sizing = Gtk.TreeViewColumnSizing.FIXED + column.props.fixed_width = cell_select.props.width + column.pack_start(cell_select, True) + column.set_cell_data_func(cell_select, self.__select_set_data_cb) + self.tree_view.append_column(column) + cell_favorite = CellRendererFavorite(self.tree_view) cell_favorite.connect('clicked', self.__favorite_clicked_cb) @@ -248,8 +269,30 @@ class BaseListView(Gtk.Bin): def __favorite_set_data_cb(self, column, cell, tree_model, tree_iter, data): - favorite = tree_model[tree_iter][ListModel.COLUMN_FAVORITE] - if favorite: + # Instead of querying the favorite-status from the "cached" + # entries in listmodel, hit the DS, and retrieve the persisted + # favorite-status. + # This solves the issue in "Multi-Select", wherein the + # listview is inhibited from refreshing. Now, if the user + # clicks favorite-star-icon(s), the change(s) is(are) written + # to the DS, but no refresh takes place. Thus, in order to have + # the change(s) reflected on the UI, we need to hit the DS for + # querying the favorite-status (instead of relying on the + # cached-listmodel. + uid = tree_model[tree_iter][ListModel.COLUMN_UID] + if uid is None: + return + + try: + metadata = model.get(uid) + except: + return + + favorite = None + if 'keep' in metadata.keys(): + favorite = str(metadata['keep']) + + if favorite == '1': client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) cell.props.xo_color = color @@ -257,6 +300,11 @@ class BaseListView(Gtk.Bin): cell.props.xo_color = None def __favorite_clicked_cb(self, cell, path): + # If this is a remote-share, return without doing any + # processing. + if model.is_current_mount_point_for_remote_share(model.LIST_VIEW): + return + row = self._model[path] metadata = model.get(row[ListModel.COLUMN_UID]) if not model.is_editable(metadata): @@ -265,7 +313,94 @@ class BaseListView(Gtk.Bin): metadata['keep'] = '0' else: metadata['keep'] = '1' - model.write(metadata, update_mtime=False) + + from jarabe.journal.journalactivity import get_mount_point + metadata['mountpoint'] = get_mount_point() + + model.update_only_metadata_and_preview_files_and_return_file_paths(metadata) + self.__redraw_view_if_necessary() + + def __select_set_data_cb(self, column, cell, tree_model, tree_iter, + data): + uid = tree_model[tree_iter][ListModel.COLUMN_UID] + if uid is None: + return + + # Hack to associate the cell with the metadata, so that it (the + # cell) is available offline as well (example during + # batch-operations, when the processing has to be done, without + # actually clicking any cell. + try: + metadata = model.get(uid) + except: + # https://dev.laptop.org.au/issues/1119 + # http://bugs.sugarlabs.org/ticket/3344 + # Occurs, when copying entries from journal to pen-drive. + # Simply swallow the exception, and return, as this too, + # like the above case, does not have any impact on the + # functionality. + return + + metadata['cell'] = cell + tree_model.update_uid_metadata_assoc(uid, metadata) + + self.do_ui_select_change(metadata) + + def __cell_select_clicked_cb(self, cell, path): + row = self._model[path] + treeiter = self._model.get_iter(path) + metadata = model.get(row[ListModel.COLUMN_UID]) + self.do_backend_select_change(metadata) + + def do_ui_select_change(self, metadata): + tree_model = self.get_model() + selected = tree_model.get_selected_value(metadata['uid']) + + if 'cell' in metadata.keys(): + cell = metadata['cell'] + if selected: + cell.props.icon_name = 'emblem-checked' + else: + cell.props.icon_name = 'emblem-unchecked' + + def do_backend_select_change(self, metadata): + uid = metadata['uid'] + selected = self._model.get_selected_value(uid) + + self._model.set_selected_value(uid, not selected) + self._process_new_selected_status(not selected) + + def _process_new_selected_status(self, new_status): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal_toolbar_box = journal.get_toolbar_box() + + self.__redraw_view_if_necessary() + + if new_status == False: + self._selected_entries = self._selected_entries - 1 + journal_toolbar_box.process_new_deselected_entry_in_multi_select() + GObject.idle_add(self._post_backend_processing) + else: + self._selected_entries = self._selected_entries + 1 + journal.get_list_view().inhibit_refresh(True) + journal.switch_to_editing_mode(True) + + # For the case, when we are switching to editing-mode. + # The previous call won't actually redraw, as we are not in + # editing-mode that time. + self.__redraw_view_if_necessary() + + journal.get_toolbar_box().process_new_selected_entry_in_multi_select() + + def _post_backend_processing(self): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + if self._selected_entries == 0: + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + journal.get_list_view().refresh() def update_with_query(self, query_dict): logging.debug('ListView.update_with_query') @@ -282,6 +417,11 @@ class BaseListView(Gtk.Bin): self.refresh() def refresh(self): + if not self._inhibit_refresh: + self.set_sensitive(True) + self.proceed_with_refresh() + + def proceed_with_refresh(self): logging.debug('ListView.refresh query %r', self._query) self._stop_progress_bar() @@ -482,6 +622,64 @@ class BaseListView(Gtk.Bin): self.update_dates() return True + def get_model(self): + return self._model + + def inhibit_refresh(self, inhibit): + self._inhibit_refresh = inhibit + + def __redraw_view_if_necessary(self): + from jarabe.journal.journalactivity import get_journal + if not get_journal().is_editing_mode_present(): + return + + # First, get the total number of entries, for which the + # batch-operation is under progress. + from jarabe.journal.palettes import get_current_action_item + + current_action_item = get_current_action_item() + if current_action_item is None: + # A single checkbox has been clicked/unclicked. + self.__redraw() + return + + total_items = current_action_item.get_number_of_entries_to_operate_upon() + + # Then, get the current entry being processed. + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + current_entry_number = journal.get_toolbar_box().get_current_entry_number() + + # Redraw, if "current_entry_number" is 10. + if current_entry_number == 10: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + # Redraw, if this is the last entry. + if current_entry_number == total_items: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + # Redraw, if this is the 20% interval. + twenty_percent_of_total_items = total_items / 5 + if twenty_percent_of_total_items < 10: + return + + if (current_entry_number % twenty_percent_of_total_items) == 0: + self.__log(current_entry_number, total_items) + self.__redraw() + return + + def __log(self, current_entry_number, total_items): + pass + + def __redraw(self): + tree_view_window = self.tree_view.get_bin_window() + tree_view_window.hide() + tree_view_window.show() + class ListView(BaseListView): __gtype_name__ = 'JournalListView' @@ -497,8 +695,8 @@ class ListView(BaseListView): ([])), } - def __init__(self): - BaseListView.__init__(self) + def __init__(self, is_object_chooser=False): + BaseListView.__init__(self, is_object_chooser) self._is_dragging = False self.tree_view.connect('drag-begin', self.__drag_begin_cb) @@ -559,11 +757,25 @@ class ListView(BaseListView): self.emit('volume-error', message, severity) def __icon_clicked_cb(self, cell, path): + # For locally-mounted remote shares, we do not want to launch + # by clicking on the icons. + # So, check if this is a part of locally-mounted-remote share, + # and if yes, return, without doing anything. + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + return + row = self.tree_view.get_model()[path] metadata = model.get(row[ListModel.COLUMN_UID]) misc.resume(metadata) def __cell_title_edited_cb(self, cell, path, new_text): + from jarabe.journal.journalactivity import get_journal, \ + get_mount_point + if get_journal().is_editing_mode_present(): + return + row = self._model[path] metadata = model.get(row[ListModel.COLUMN_UID]) metadata['title'] = new_text @@ -592,6 +804,25 @@ class CellRendererFavorite(CellRendererIcon): self.props.prelit_stroke_color = prelit_color.get_stroke_color() self.props.prelit_fill_color = prelit_color.get_fill_color() + def do_render(self, cr, widget, background_area, cell_area, flags): + # If this is a remote-share, mask the "PRELIT" flag. + if model.is_current_mount_point_for_remote_share(model.LIST_VIEW): + flags = flags & (~(Gtk.CellRendererState.PRELIT)) + + CellRendererIcon.do_render(self, cr, widget, background_area, cell_area, flags) + +class CellRendererToggle(CellRendererIcon): + __gtype_name__ = 'JournalCellRendererSelect' + + def __init__(self, tree_view): + CellRendererIcon.__init__(self, tree_view) + + self.props.width = style.GRID_CELL_SIZE + self.props.height = style.GRID_CELL_SIZE + self.props.size = style.SMALL_ICON_SIZE + self.props.icon_name = 'checkbox-unchecked' + self.props.mode = Gtk.CellRendererMode.ACTIVATABLE + class CellRendererDetail(CellRendererIcon): __gtype_name__ = 'JournalCellRendererDetail' @@ -636,6 +867,11 @@ class CellRendererActivityIcon(CellRendererIcon): if not self._show_palette: return None + # Also, if we are in batch-operations mode, return 'None' + from jarabe.journal.journalactivity import get_journal + if get_journal().is_editing_mode_present(): + return None + tree_model = self.tree_view.get_model() metadata = tree_model.get_metadata(self.props.palette_invoker.path) diff --git a/src/jarabe/journal/misc.py b/src/jarabe/journal/misc.py index efd0dbe..f627c1b 100644 --- a/src/jarabe/journal/misc.py +++ b/src/jarabe/journal/misc.py @@ -40,6 +40,7 @@ from jarabe.journal.journalentrybundle import JournalEntryBundle from jarabe.journal import model from jarabe.journal import journalwindow +_NOT_AVAILABLE = _('Not available') def _get_icon_for_mime(mime_type): generic_types = mime.get_all_generic_types() @@ -312,3 +313,48 @@ def get_icon_color(metadata): return XoColor(client.get_string('/desktop/sugar/user/color')) else: return XoColor(metadata['icon-color']) + + +def get_xo_serial(): + _OFW_TREE = '/ofw' + _PROC_TREE = '/proc/device-tree' + _SN = 'serial-number' + + serial_no = None + if os.path.exists(os.path.join(_OFW_TREE, _SN)): + serial_no = read_file(os.path.join(_OFW_TREE, _SN)) + elif os.path.exists(os.path.join(_PROC_TREE, _SN)): + serial_no = read_file(os.path.join(_PROC_TREE, _SN)) + + if serial_no is None: + serial_no = _NOT_AVAILABLE + + # Remove the trailing binary character, else DBUS will crash. + return serial_no.rstrip('\x00') + + +def read_file(path): + if os.access(path, os.R_OK) == 0: + return None + + fd = open(path, 'r') + value = fd.read() + fd.close() + if value: + value = value.strip('\n') + return value + else: + logging.debug('No information in file or directory: %s', path) + return None + + +def get_nick(): + client = GConf.Client.get_default() + return client.get_string("/desktop/sugar/user/nick") + + +def get_backup_identifier(): + serial_number = get_xo_serial() + if serial_number is _NOT_AVAILABLE: + serial_number = get_nick() + return serial_number diff --git a/src/jarabe/journal/model.py b/src/jarabe/journal/model.py index 0a5b354..c9e08ec 100644 --- a/src/jarabe/journal/model.py +++ b/src/jarabe/journal/model.py @@ -16,6 +16,7 @@ import logging import os +import stat import errno import subprocess from datetime import datetime @@ -37,6 +38,8 @@ from sugar3 import dispatch from sugar3 import mime from sugar3 import util +from jarabe.journal import webdavmanager + DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' @@ -50,14 +53,99 @@ PROPERTIES = ['activity', 'activity_id', 'buddies', 'bundle_id', MIN_PAGES_TO_CACHE = 3 MAX_PAGES_TO_CACHE = 5 +WEBDAV_MOUNT_POINT = '/tmp/' +LOCAL_SHARES_MOUNT_POINT = '/var/www/web1/web/' + JOURNAL_METADATA_DIR = '.Sugar-Metadata' +LIST_VIEW = 1 +DETAIL_VIEW = 2 + _datastore = None created = dispatch.Signal() updated = dispatch.Signal() deleted = dispatch.Signal() +SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME_PATH = \ + '/desktop/sugar/network/school_server_ip_address_or_dns_name' +IS_PEER_TO_PEER_SHARING_AVAILABLE_PATH = \ + '/desktop/sugar/network/is_peer_to_peer_sharing_available' + +client = GConf.Client.get_default() +SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME = client.get_string(SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME_PATH) or '' +IS_PEER_TO_PEER_SHARING_AVAILABLE = client.get_bool(IS_PEER_TO_PEER_SHARING_AVAILABLE_PATH) + + + +def is_school_server_present(): + return not (SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME is '') + + +def is_peer_to_peer_sharing_available(): + return IS_PEER_TO_PEER_SHARING_AVAILABLE == True + + +def _get_mount_point(path): + dir_path = os.path.dirname(path) + while dir_path: + if os.path.ismount(dir_path): + return dir_path + else: + dir_path = dir_path.rsplit(os.sep, 1)[0] + return None + + +def _check_remote_sharing_mount_point(mount_point, share_type): + from jarabe.journal.journalactivity import get_journal + + mount_point_button = get_journal().get_volumes_toolbar()._get_button_for_mount_point(mount_point) + if mount_point_button._share_type == share_type: + return True + return False + + +def is_mount_point_for_school_server(mount_point): + from jarabe.journal.volumestoolbar import SHARE_TYPE_SCHOOL_SERVER + return _check_remote_sharing_mount_point(mount_point, SHARE_TYPE_SCHOOL_SERVER) + + +def is_mount_point_for_peer_share(mount_point): + from jarabe.journal.volumestoolbar import SHARE_TYPE_PEER + return _check_remote_sharing_mount_point(mount_point, SHARE_TYPE_PEER) + + +def is_current_mount_point_for_remote_share(view_type): + from jarabe.journal.journalactivity import get_journal, get_mount_point + if view_type == LIST_VIEW: + current_mount_point = get_mount_point() + elif view_type == DETAIL_VIEW: + current_mount_point = get_journal().get_detail_toolbox().get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(current_mount_point): + return True + return False + + +def extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(path): + """ + Path is of type :: + + /tmp/1.2.3.4/webdav/a.txt; OR + /tmp/this.is.dns.name/a.txt + """ + return path.split('/')[2] + + +def is_mount_point_for_locally_mounted_remote_share(mount_point): + """ + The mount-point can be either of the ip-Address, or the DNS name. + More importantly, whatever the "name" be, it does NOT have a + forward-slash. + """ + return mount_point.find(WEBDAV_MOUNT_POINT) == 0 + + class _Cache(object): __gtype_name__ = 'model_Cache' @@ -422,6 +510,127 @@ class InplaceResultSet(BaseResultSet): return +class RemoteShareResultSet(object): + def __init__(self, ip_address_or_dns_name, query): + self._ip_address_or_dns_name = ip_address_or_dns_name + self._file_list = [] + + self.ready = dispatch.Signal() + self.progress = dispatch.Signal() + + # First time, query is none. + if query is None: + return + + query_text = query.get('query', '') + if query_text.startswith('"') and query_text.endswith('"'): + self._regex = re.compile('*%s*' % query_text.strip(['"'])) + elif query_text: + expression = '' + for word in query_text.split(' '): + expression += '(?=.*%s.*)' % word + self._regex = re.compile(expression, re.IGNORECASE) + else: + self._regex = None + + if query.get('timestamp', ''): + self._date_start = int(query['timestamp']['start']) + self._date_end = int(query['timestamp']['end']) + else: + self._date_start = None + self._date_end = None + + self._mime_types = query.get('mime_type', []) + + self._sort = query.get('order_by', ['+timestamp'])[0] + + def setup(self): + try: + metadata_list_complete = webdavmanager.get_remote_webdav_share_metadata(self._ip_address_or_dns_name) + except Exception, e: + metadata_list_complete = [] + + for metadata in metadata_list_complete: + + add_to_list = False + if self._regex is not None: + for f in ['fulltext', 'title', + 'description', 'tags']: + if f in metadata and \ + self._regex.match(metadata[f]): + add_to_list = True + break + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._date_start is not None: + if metadata['timestamp'] > self._date_start: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._date_end is not None: + if metadata['timestamp'] < self._date_end: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._mime_types: + mime_type = metadata['mime_type'] + if mime_type in self._mime_types: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + # If control reaches here, the current metadata has passed + # out all filter-tests. + file_info = (metadata['timestamp'], + metadata['creation_time'], + metadata['filesize'], + metadata) + self._file_list.append(file_info) + + if self._sort[1:] == 'filesize': + keygetter = itemgetter(2) + elif self._sort[1:] == 'creation_time': + keygetter = itemgetter(1) + else: + # timestamp + keygetter = itemgetter(0) + + self._file_list.sort(lambda a, b: cmp(b, a), + key=keygetter, + reverse=(self._sort[0] == '-')) + + self.ready.send(self) + + def get_length(self): + return len(self._file_list) + + length = property(get_length) + + def seek(self, position): + self._position = position + + def read(self): + modified_timestamp, creation_timestamp, filesize, metadata = self._file_list[self._position] + return metadata + + def stop(self): + self._stopped = True + + def _get_file_metadata(path, stat, fetch_preview=True): """Return the metadata from the corresponding file. @@ -434,9 +643,13 @@ def _get_file_metadata(path, stat, fetch_preview=True): metadata = _get_file_metadata_from_json(dir_path, filename, fetch_preview) if metadata: if 'filesize' not in metadata: - metadata['filesize'] = stat.st_size + if stat is not None: + metadata['filesize'] = stat.st_size return metadata + if stat is None: + raise ValueError('File does not exist') + mime_type, uncertain_result_ = Gio.content_type_guess(filename=path, data=None) return {'uid': path, @@ -457,10 +670,17 @@ def _get_file_metadata_from_json(dir_path, filename, fetch_preview): If the metadata is corrupted we do remove it and the preview as well. """ + + # In case of nested mount-points, (eg. ~/Documents/in1/in2/in3.txt), + # "dir_path = ~/Documents/in1/in2"; while + # "metadata_dir_path = ~/Documents". + from jarabe.journal.journalactivity import get_mount_point + metadata_dir_path = get_mount_point() + metadata = None - metadata_path = os.path.join(dir_path, JOURNAL_METADATA_DIR, + metadata_path = os.path.join(metadata_dir_path, JOURNAL_METADATA_DIR, filename + '.metadata') - preview_path = os.path.join(dir_path, JOURNAL_METADATA_DIR, + preview_path = os.path.join(metadata_dir_path, JOURNAL_METADATA_DIR, filename + '.preview') if not os.path.exists(metadata_path): @@ -529,6 +749,9 @@ def find(query_, page_size): if mount_points[0] == '/': return DatastoreResultSet(query, page_size) + elif is_mount_point_for_locally_mounted_remote_share(mount_points[0]): + ip_address = extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(mount_points[0]) + return RemoteShareResultSet(ip_address, query) else: return InplaceResultSet(query, page_size, mount_points[0]) @@ -546,8 +769,12 @@ def _get_mount_point(path): def get(object_id): """Returns the metadata for an object """ - if os.path.exists(object_id): - stat = os.stat(object_id) + if (object_id[0] == '/'): + if os.path.exists(object_id): + stat = os.stat(object_id) + else: + stat = None + metadata = _get_file_metadata(object_id, stat) metadata['mountpoint'] = _get_mount_point(object_id) else: @@ -620,7 +847,21 @@ def delete(object_id): def copy(metadata, mount_point): """Copies an object to another mount point """ + # In all cases (except one), "copy" means the actual duplication of + # the content. + # Only in case of remote downloading, the content is first copied + # to "/tmp" folder. In those cases, copying would refer to a mere + # renaming. + transfer_ownership = False + + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(current_mount_point): + transfer_ownership = True + metadata = get(metadata['uid']) + if mount_point == '/' and metadata['icon-color'] == '#000000,#ffffff': client = GConf.Client.get_default() metadata['icon-color'] = client.get_string('/desktop/sugar/user/color') @@ -631,7 +872,7 @@ def copy(metadata, mount_point): metadata['mountpoint'] = mount_point del metadata['uid'] - return write(metadata, file_path, transfer_ownership=False) + return write(metadata, file_path, transfer_ownership=transfer_ownership) def write(metadata, file_path='', update_mtime=True, transfer_ownership=True): @@ -653,22 +894,113 @@ def write(metadata, file_path='', update_mtime=True, transfer_ownership=True): object_id = _get_datastore().create(dbus.Dictionary(metadata), file_path, transfer_ownership) + elif metadata.get('mountpoint', '/') == (WEBDAV_MOUNT_POINT + SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME): + filename = metadata['title'] + + ip_address_or_dns_name = SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME + webdavmanager.get_remote_webdav_share_metadata(ip_address_or_dns_name) + + data_webdav_manager = \ + webdavmanager.get_data_webdav_manager(ip_address_or_dns_name) + metadata_webdav_manager = \ + webdavmanager.get_metadata_webdav_manager(ip_address_or_dns_name) + + + # If we get a resource by this name, there is already an entry + # on the server with this name; we do not want to do any + # overwrites. + data_resource = webdavmanager.get_resource_by_resource_key(data_webdav_manager, + '/webdav/' + filename) + metadata_resource = webdavmanager.get_resource_by_resource_key(metadata_webdav_manager, + '/webdav/.Sugar-Metadata/' + filename + '.metadata') + if (data_resource is not None) or (metadata_resource is not None): + raise Exception(_('Entry already present on the server with ' + 'this name. Try again after renaming.')) + + # No entry for this name present. + # So, first write the metadata- and preview-file to temporary + # locations. + metadata_file_path, preview_file_path = \ + _write_metadata_and_preview_files_and_return_file_paths(metadata, + filename) + + # Finally, + # Upload the data file. + webdavmanager.add_resource_by_resource_key(data_webdav_manager, + filename, + file_path) + + # Upload the preview file. + if preview_file_path is not None: + webdavmanager.add_resource_by_resource_key(metadata_webdav_manager, + filename + '.preview', + preview_file_path) + + # Upload the metadata file. + # + # Note that this needs to be the last step. If there was any + # error uploading the data- or the preview-file, control would + # not reach here. + # + # In other words, the control reaches here only if the data- + # and the preview- files have been uploaded. Finally, IF this + # file is successfully uploaded, we have the guarantee that all + # files for a particular journal entry are in place. + webdavmanager.add_resource_by_resource_key(metadata_webdav_manager, + filename + '.metadata', + metadata_file_path) + + + object_id = 'doesn\'t matter' + else: - object_id = _write_entry_on_external_device(metadata, file_path) + object_id = _write_entry_on_external_device(metadata, + file_path, + transfer_ownership) return object_id -def _rename_entry_on_external_device(file_path, destination_path, - metadata_dir_path): +def make_file_fully_permissible(file_path): + fd = os.open(file_path, os.O_RDONLY) + os.fchmod(fd, stat.S_IRWXU | stat.S_IRWXG |stat.S_IRWXO) + os.close(fd) + + +def _rename_entry_on_external_device(file_path, destination_path): """Rename an entry with the associated metadata on an external device.""" old_file_path = file_path if old_file_path != destination_path: - os.rename(file_path, destination_path) + # Strangely, "os.rename" works fine on sugar-jhbuild, but fails + # on XOs, wih the OSError 13 ("invalid cross-device link"). So, + # using the system call "mv". + os.system('mv "%s" "%s"' % (file_path, destination_path)) + make_file_fully_permissible(destination_path) + + + # In renaming, we want to delete the metadata-, and preview- + # files of the current mount-point, and not the destination + # mount-point. + # But we also need to ensure that the directory of + # 'old_file_path' and 'destination_path' are not same. + if os.path.dirname(old_file_path) == os.path.dirname(destination_path): + return + + from jarabe.journal.journalactivity import get_mount_point + + # Also, as a special case, the metadata- and preview-files of + # the remote-shares must never be deleted. For them, only the + # data-file needs to be moved. + if is_mount_point_for_locally_mounted_remote_share(get_mount_point()): + return + + + source_metadata_dir_path = get_mount_point() + '/.Sugar-Metadata' + old_fname = os.path.basename(file_path) - old_files = [os.path.join(metadata_dir_path, + old_files = [os.path.join(source_metadata_dir_path, old_fname + '.metadata'), - os.path.join(metadata_dir_path, + os.path.join(source_metadata_dir_path, old_fname + '.preview')] for ofile in old_files: if os.path.exists(ofile): @@ -679,41 +1011,32 @@ def _rename_entry_on_external_device(file_path, destination_path, 'for file=%s', ofile, old_fname) -def _write_entry_on_external_device(metadata, file_path): - """Create and update an entry copied from the - DS to an external storage device. - - Besides copying the associated file a file for the preview - and one for the metadata are stored in the hidden directory - .Sugar-Metadata. - - This function handles renames of an entry on the - external device and avoids name collisions. Renames are - handled failsafe. - - """ - if 'uid' in metadata and os.path.exists(metadata['uid']): - file_path = metadata['uid'] +def _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name): + metadata_copy = metadata.copy() + metadata_copy.pop('mountpoint', None) + metadata_copy.pop('uid', None) - if not file_path or not os.path.exists(file_path): - raise ValueError('Entries without a file cannot be copied to ' - 'removable devices') - if not metadata.get('title'): - metadata['title'] = _('Untitled') - file_name = get_file_name(metadata['title'], metadata['mime_type']) + # For copying to School-Server, we need to retain this property. + # Else wise, I have no idea why this property is being removed !! + if (is_mount_point_for_locally_mounted_remote_share(metadata.get('mountpoint', '/')) == False) and \ + (metadata.get('mountpoint', '/') != LOCAL_SHARES_MOUNT_POINT): + metadata_copy.pop('filesize', None) - destination_path = os.path.join(metadata['mountpoint'], file_name) - if destination_path != file_path: - file_name = get_unique_file_name(metadata['mountpoint'], file_name) - destination_path = os.path.join(metadata['mountpoint'], file_name) - clean_name, extension_ = os.path.splitext(file_name) - metadata['title'] = clean_name + # For journal case, there is the special treatment. + if metadata.get('mountpoint', '/') == '/': + if metadata.get('uid', ''): + object_id = _get_datastore().update(metadata['uid'], + dbus.Dictionary(metadata), + '', + False) + else: + object_id = _get_datastore().create(dbus.Dictionary(metadata), + '', + False) + return - metadata_copy = metadata.copy() - metadata_copy.pop('mountpoint', None) - metadata_copy.pop('uid', None) - metadata_copy.pop('filesize', None) metadata_dir_path = os.path.join(metadata['mountpoint'], JOURNAL_METADATA_DIR) @@ -742,11 +1065,64 @@ def _write_entry_on_external_device(metadata, file_path): os.close(fh) os.rename(fn, os.path.join(metadata_dir_path, preview_fname)) - if not os.path.dirname(destination_path) == os.path.dirname(file_path): - shutil.copy(file_path, destination_path) + metadata_destination_path = os.path.join(metadata_dir_path, file_name + '.metadata') + make_file_fully_permissible(metadata_destination_path) + if preview: + preview_destination_path = os.path.join(metadata_dir_path, preview_fname) + make_file_fully_permissible(preview_destination_path) + else: + preview_destination_path = None + + return (metadata_destination_path, preview_destination_path) + + +def update_only_metadata_and_preview_files_and_return_file_paths(metadata): + file_name = get_file_name(metadata['title'], metadata['mime_type']) + _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name) + + +def _write_entry_on_external_device(metadata, file_path, + transfer_ownership): + """Create and update an entry copied from the + DS to an external storage device. + + Besides copying the associated file a file for the preview + and one for the metadata are stored in the hidden directory + .Sugar-Metadata. + + This function handles renames of an entry on the + external device and avoids name collisions. Renames are + handled failsafe. + + """ + if 'uid' in metadata and os.path.exists(metadata['uid']): + file_path = metadata['uid'] + + if not file_path or not os.path.exists(file_path): + raise ValueError('Entries without a file cannot be copied to ' + 'removable devices') + + if not metadata.get('title'): + metadata['title'] = _('Untitled') + file_name = get_file_name(metadata['title'], metadata['mime_type']) + + destination_path = os.path.join(metadata['mountpoint'], file_name) + if destination_path != file_path: + file_name = get_unique_file_name(metadata['mountpoint'], file_name) + destination_path = os.path.join(metadata['mountpoint'], file_name) + clean_name, extension_ = os.path.splitext(file_name) + metadata['title'] = clean_name + + _write_metadata_and_preview_files_and_return_file_paths(metadata, + file_name) + + if (os.path.dirname(destination_path) == os.path.dirname(file_path)) or \ + (transfer_ownership == True): + _rename_entry_on_external_device(file_path, destination_path) else: - _rename_entry_on_external_device(file_path, destination_path, - metadata_dir_path) + shutil.copy(file_path, destination_path) + make_file_fully_permissible(destination_path) object_id = destination_path created.send(None, object_id=object_id) @@ -796,7 +1172,17 @@ def is_editable(metadata): if metadata.get('mountpoint', '/') == '/': return True else: - return os.access(metadata['mountpoint'], os.W_OK) + # sl#3605: Instead of relying on mountpoint property being + # present in the metadata, use journalactivity api. + # This would work seamlessly, as "Details View' is + # called, upon an entry in the context of a singular + # mount-point. + from jarabe.journal.journalactivity import get_mount_point + mount_point = get_mount_point() + + if is_mount_point_for_locally_mounted_remote_share(mount_point): + return False + return os.access(mount_point, os.W_OK) def get_documents_path(): diff --git a/src/jarabe/journal/objectchooser.py b/src/jarabe/journal/objectchooser.py index d860b0d..45e72af 100644 --- a/src/jarabe/journal/objectchooser.py +++ b/src/jarabe/journal/objectchooser.py @@ -16,15 +16,20 @@ from gettext import gettext as _ import logging +import os from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk from gi.repository import Wnck +from sugar3 import env + from sugar3.graphics import style from sugar3.graphics.toolbutton import ToolButton +from sugar3.datastore import datastore + from jarabe.journal.listview import BaseListView from jarabe.journal.listmodel import ListModel from jarabe.journal.journaltoolbox import MainToolbox @@ -47,6 +52,7 @@ class ObjectChooser(Gtk.Window): self.set_border_width(style.LINE_WIDTH) self._selected_object_id = None + self._callback = None self.add_events(Gdk.EventMask.VISIBILITY_NOTIFY_MASK) self.connect('visibility-notify-event', @@ -111,6 +117,15 @@ class ObjectChooser(Gtk.Window): self._selected_object_id = uid self.emit('response', Gtk.ResponseType.ACCEPT) + if self._callback is not None: + self._callback(self._selected_object_id) + + def get_selected_object(self): + if self._selected_object_id is None: + return None + else: + return datastore.get(self._selected_object_id) + def __delete_event_cb(self, chooser, event): self.emit('response', Gtk.ResponseType.DELETE_EVENT) @@ -121,6 +136,8 @@ class ObjectChooser(Gtk.Window): def __close_button_clicked_cb(self, button): self.emit('response', Gtk.ResponseType.DELETE_EVENT) + if self._callback is not None: + self._callback(self._selected_object_id) def get_selected_object_id(self): return self._selected_object_id @@ -140,6 +157,9 @@ class ObjectChooser(Gtk.Window): def __clear_clicked_cb(self, list_view): self._toolbar.clear_query() + def _set_callback(self, callback): + self._callback = callback + class TitleBox(VolumesToolbar): __gtype_name__ = 'TitleBox' diff --git a/src/jarabe/journal/palettes.py b/src/jarabe/journal/palettes.py index 43f9905..f770d55 100644 --- a/src/jarabe/journal/palettes.py +++ b/src/jarabe/journal/palettes.py @@ -15,6 +15,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA from gettext import gettext as _ +from gettext import ngettext import logging import os @@ -38,6 +39,61 @@ from jarabe.model import mimeregistry from jarabe.journal import misc from jarabe.journal import model from jarabe.journal import journalwindow +from jarabe.journal import webdavmanager +from jarabe.journal.journalwindow import freeze_ui, \ + unfreeze_ui, \ + show_normal_cursor, \ + show_waiting_cursor + +from webdav.Connection import WebdavError + + +friends_model = friends.get_model() + +_copy_menu_helper = None +_current_action_item = None + +USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE = _('Cannot perform request. Connection failed.') + + +class PassphraseDialog(Gtk.Dialog): + def __init__(self, callback, metadata): + Gtk.Dialog.__init__(self, flags=Gtk.DialogFlags.MODAL) + self._callback = callback + self._metadata = metadata + self.set_title(_('Passphrase required')) + + # TRANS: Please do not translate the '%s'. + label_text = _('Please enter the passphrase for "%s"' % metadata['title']) + label = Gtk.Label(label_text) + self.vbox.pack_start(label, True, True, 0) + + self.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK) + self.set_default_response(Gtk.ResponseType.OK) + self.add_key_entry() + + self.connect('response', self._key_dialog_response_cb) + self.show_all() + + def add_key_entry(self): + self._entry = Gtk.Entry() + self._entry.connect('activate', self._entry_activate_cb) + self.vbox.pack_start(self._entry, True, True, 0) + self.vbox.set_spacing(6) + self.vbox.show_all() + + self._entry.grab_focus() + + def _entry_activate_cb(self, entry): + self.response(Gtk.ResponseType.OK) + + def get_response_object(self): + return self._response + + def _key_dialog_response_cb(self, widget, response_id): + self.hide() + GObject.idle_add(self._callback, self._metadata, + self._entry.get_text()) class ObjectPalette(Palette): @@ -68,6 +124,9 @@ class ObjectPalette(Palette): Palette.__init__(self, primary_text=title, icon=activity_icon) + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + if misc.get_activities(metadata) or misc.is_bundle(metadata): if metadata.get('activity_id', ''): resume_label = _('Resume') @@ -77,10 +136,14 @@ class ObjectPalette(Palette): resume_with_label = _('Start with') menu_item = MenuItem(resume_label, 'activity-start') menu_item.connect('activate', self.__start_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() menu_item = MenuItem(resume_with_label, 'activity-start') + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() start_with_menu = StartWithMenu(self._metadata) @@ -99,6 +162,15 @@ class ObjectPalette(Palette): self.menu.append(menu_item) menu_item.show() copy_menu = CopyMenu(metadata) + copy_menu_helper = get_copy_menu_helper() + + metadata_list = [] + metadata_list.append(metadata) + copy_menu_helper.insert_copy_to_menu_items(copy_menu, + metadata_list, + False, + False, + False) copy_menu.connect('volume-error', self.__volume_error_cb) menu_item.set_submenu(copy_menu) @@ -112,6 +184,8 @@ class ObjectPalette(Palette): menu_item.show() menu_item = MenuItem(_('Send to'), 'document-send') + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() @@ -127,6 +201,8 @@ class ObjectPalette(Palette): menu_item = MenuItem(_('Erase'), 'list-remove') menu_item.connect('activate', self.__erase_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() @@ -197,123 +273,798 @@ class CopyMenu(Gtk.Menu): __gsignals__ = { 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), + ([str, str])), } def __init__(self, metadata): Gtk.Menu.__init__(self) - self._metadata = metadata - clipboard_menu = ClipboardMenu(self._metadata) - clipboard_menu.set_image(Icon(icon_name='toolbar-edit', - icon_size=Gtk.IconSize.MENU)) - clipboard_menu.connect('volume-error', self.__volume_error_cb) - self.append(clipboard_menu) - clipboard_menu.show() +class ActionItem(GObject.GObject): + """ + This class implements the course of actions that happens when clicking + upon an Action-Item (eg. Batch-Copy-Toolbar-button; + Actual-Batch-Copy-To-Journal-button; + Actual-Batch-Copy-To-Documents-button; + Actual-Batch-Copy-To-Mounted-Drive-button; + Actual-Batch-Copy-To-Clipboard-button; + Single-Copy-To-Journal-button; + Single-Copy-To-Documents-button; + Single-Copy-To-Mounted-Drive-button; + Single-Copy-To-Clipboard-button; + Batch-Erase-Button; + Select-None-Toolbar-button; + Select-All-Toolbar-button + """ + __gtype_name__ = 'JournalActionItem' + + def __init__(self, label, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode, + auto_deselect_source_entries, + need_to_popup_options, + operate_on_deselected_entries, + show_not_completed_ops_info): + GObject.GObject.__init__(self) + + self._label = label + + # Make a copy. + self._immutable_metadata_list = [] + for metadata in metadata_list: + self._immutable_metadata_list.append(metadata) + + self._metadata_list = metadata_list + self._show_progress_info_alert = show_progress_info_alert + self._batch_mode = batch_mode + self._auto_deselect_source_entries = \ + auto_deselect_source_entries + self._need_to_popup_options = \ + need_to_popup_options + self._operate_on_deselected_entries = \ + operate_on_deselected_entries + self._show_not_completed_ops_info = \ + show_not_completed_ops_info + + actionable_signal = self._get_actionable_signal() + + if need_to_popup_options: + self.connect(actionable_signal, self._pre_fill_and_pop_up_options) + else: + if show_editing_alert: + self.connect(actionable_signal, self._show_editing_alert) + else: + self.connect(actionable_signal, + self._pre_operate_per_action, + Gtk.ResponseType.OK) + + def _get_actionable_signal(self): + """ + Some widgets like 'buttons' have 'clicked' as actionable signal; + some like 'menuitems' have 'activate' as actionable signal. + """ + + raise NotImplementedError + + def _pre_fill_and_pop_up_options(self, widget_clicked): + self._set_current_action_item_widget() + self._fill_and_pop_up_options(widget_clicked) + + def _fill_and_pop_up_options(self, widget_clicked): + """ + Eg. Batch-Copy-Toolbar-button does not do anything by itself + useful; but rather pops-up the actual 'copy-to' options. + """ + + raise NotImplementedError + + def _show_editing_alert(self, widget_clicked): + """ + Upon clicking the actual operation button (eg. + Batch-Erase-Button and Batch-Copy-To-Clipboard button; BUT NOT + Batch-Copy-Toolbar-button, since it does not do anything + actually useful, but only pops-up the actual 'copy-to' options. + """ + + freeze_ui() + GObject.idle_add(self.__show_editing_alert_after_freezing_ui, + widget_clicked) + + def __show_editing_alert_after_freezing_ui(self, widget_clicked): + self._set_current_action_item_widget() + + alert_parameters = self._get_editing_alert_parameters() + title = alert_parameters[0] + message = alert_parameters[1] + operation = alert_parameters[2] + + from jarabe.journal.journalactivity import get_journal + get_journal().update_confirmation_alert(title, message, + self._pre_operate_per_action, + None) + + def _get_editing_alert_parameters(self): + """ + Get the alert parameters for widgets that can show editing + alert. + """ + + self._metadata_list = self._get_metadata_list() + entries_len = len(self._metadata_list) + + title = self._get_editing_alert_title() + message = self._get_editing_alert_message(entries_len) + operation = self._get_editing_alert_operation() + + return (title, message, operation) + + def _get_list_model_len(self): + """ + Get the total length of the model under view. + """ + + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + return len(journal.get_list_view().get_model()) + + def _get_metadata_list(self): + """ + For batch-mode, get the metadata list, according to button-type. + For eg, Select-All-Toolbar-button operates on non-selected entries; + while othere operate on selected-entries. + + For single-mode, simply copy from the + "immutable_metadata_list". + """ + + if self._batch_mode: + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + if self._operate_on_deselected_entries: + metadata_list = journal.get_metadata_list(False) + else: + metadata_list = journal.get_metadata_list(True) - if self._metadata['mountpoint'] != '/': - client = GConf.Client.get_default() - color = XoColor(client.get_string('/desktop/sugar/user/color')) - journal_menu = VolumeMenu(self._metadata, _('Journal'), '/') - journal_menu.set_image(Icon(icon_name='activity-journal', - xo_color=color, - icon_size=Gtk.IconSize.MENU)) - journal_menu.connect('volume-error', self.__volume_error_cb) - self.append(journal_menu) - journal_menu.show() + # Make a backup copy, of this metadata_list. + self._immutable_metadata_list = [] + for metadata in metadata_list: + self._immutable_metadata_list.append(metadata) - volume_monitor = Gio.VolumeMonitor.get() - icon_theme = Gtk.IconTheme.get_default() - for mount in volume_monitor.get_mounts(): - if self._metadata['mountpoint'] == mount.get_root().get_path(): - continue - volume_menu = VolumeMenu(self._metadata, mount.get_name(), - mount.get_root().get_path()) - for name in mount.get_icon().props.names: - if icon_theme.has_icon(name): - volume_menu.set_image(Icon(icon_name=name, - icon_size=Gtk.IconSize.MENU)) - break - volume_menu.connect('volume-error', self.__volume_error_cb) - self.append(volume_menu) - volume_menu.show() + return metadata_list + else: + metadata_list = [] + for metadata in self._immutable_metadata_list: + metadata_list.append(metadata) + return metadata_list + + def _get_editing_alert_title(self): + raise NotImplementedError + + def _get_editing_alert_message(self, entries_len): + raise NotImplementedError + + def _get_editing_alert_operation(self): + raise NotImplementedError + + def _is_metadata_list_empty(self): + return (self._metadata_list is None) or \ + (len(self._metadata_list) == 0) + + def _set_current_action_item_widget(self): + """ + Only set this, if this widget achieves some effective action. + """ + if not self._need_to_popup_options: + global _current_action_item + _current_action_item = self + + def _pre_operate_per_action(self, obj, response_id): + """ + This is the stage, just before the FIRST metadata gets into its + processing cycle. + """ + freeze_ui() + GObject.idle_add(self._pre_operate_per_action_after_done_ui_freezing, + obj, response_id) + + def _pre_operate_per_action_after_done_ui_freezing(self, obj, + response_id): + self._set_current_action_item_widget() + + self._continue_operation = True + + # If the user chose to cancel the operation from the onset, + # simply proceeed to the last. + if response_id == Gtk.ResponseType.CANCEL: + unfreeze_ui() + + self._cancel_further_batch_operation_items() + self._post_operate_per_action() + return - def __volume_error_cb(self, menu_item, message, severity): - self.emit('volume-error', message, severity) + self._skip_all = False + # Also, get the initial length of the model. + self._model_len = self._get_list_model_len() -class VolumeMenu(MenuItem): - __gtype_name__ = 'JournalVolumeMenu' + # Speed Optimisation: + # =================== + # If the metadata-list is empty, fetch it; + # else we have already fetched it, when we showed the + # "editing-alert". + if len(self._metadata_list) == 0: + self._metadata_list = self._get_metadata_list() - __gsignals__ = { - 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), - } + # Set the initial length of metadata-list. + self._metadata_list_initial_len = len(self._metadata_list) - def __init__(self, metadata, label, mount_point): - MenuItem.__init__(self, label) - self._metadata = metadata - self.connect('activate', self.__copy_to_volume_cb, mount_point) + self._metadata_processed = 0 - def __copy_to_volume_cb(self, menu_item, mount_point): - file_path = model.get_file(self._metadata['uid']) + # Next, proceed with the metadata + self._pre_operate_per_metadata_per_action() + + def _pre_operate_per_metadata_per_action(self): + """ + This is the stage, just before EVERY metadata gets into doing + its actual work. + """ + + show_waiting_cursor() + GObject.idle_add(self.__pre_operate_per_metadata_per_action_after_freezing_ui) + + def __pre_operate_per_metadata_per_action_after_freezing_ui(self): + from jarabe.journal.journalactivity import get_journal + + # If there is still some metadata left, proceed with the + # metadata operation. + # Else, proceed to post-operations. + if len(self._metadata_list) > 0: + metadata = self._metadata_list.pop(0) + + # If info-alert needs to be shown, show the alert, and + # arrange for actual operation. + # Else, proceed to actual operation directly. + if self._show_progress_info_alert: + current_len = len(self._metadata_list) + + # TRANS: Do not translate the two %d, and the %s. + info_alert_message = _(' %d of %d : %s') % ( + self._metadata_list_initial_len - current_len, + self._metadata_list_initial_len, metadata['title']) + + get_journal().update_info_alert(self._get_info_alert_title(), + info_alert_message) + + # Call the core-function !! + GObject.idle_add(self._operate_per_metadata_per_action, metadata) + else: + self._post_operate_per_action() + + def _get_info_alert_title(self): + raise NotImplementedError + + def _operate_per_metadata_per_action(self, metadata): + """ + This is just a code-convenient-function, which allows + runtime-overriding. It just delegates to the actual + "self._operate" method, the actual which is determined at + runtime. + """ + + if self._continue_operation is False: + # Jump directly to the post-operation + self._post_operate_per_metadata_per_action(metadata) + else: + # Pass the callback for the post-operation-for-metadata. This + # will ensure that async-operations on the metadata are taken + # care of. + if self._operate(metadata) is False: + return + else: + self._metadata_processed = self._metadata_processed + 1 + + def _operate(self, metadata): + """ + Actual, core, productive stage for EVERY metadata. + """ + raise NotImplementedError + + def _post_operate_per_metadata_per_action(self, metadata, + response_id=None): + """ + This is the stage, just after EVERY metadata has been + processed. + """ + self._hide_info_widget_for_single_mode() + + # Toggle the corresponding checkbox - but only for batch-mode. + if self._batch_mode and self._auto_deselect_source_entries: + from jarabe.journal.journalactivity import get_journal + list_view = get_journal().get_list_view() + + list_view.do_ui_select_change(metadata) + list_view.do_backend_select_change(metadata) + + # Call the next ... + self._pre_operate_per_metadata_per_action() + + def _post_operate_per_action(self): + """ + This is the stage, just after the LAST metadata has been + processed. + """ + + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal_toolbar_box = journal.get_toolbar_box() + + if self._batch_mode and (not self._auto_deselect_source_entries): + journal_toolbar_box.display_already_selected_entries_status() + + self._process_switching_mode(None, False) + + unfreeze_ui() + + # Set the "_current_action_item" to None. + global _current_action_item + _current_action_item = None + + def _process_switching_mode(self, metadata, ok_clicked=False): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + # Necessary to do this, when the alert needs to be hidden, + # WITHOUT user-intervention. + journal.hide_alert() + + def _refresh(self): + from jarabe.journal.journalactivity import get_journal + get_journal().get_list_view().refresh() + + def _handle_single_mode_notification(self, message, severity): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal._show_alert(message, severity) + self._hide_info_widget_for_single_mode() + + def _hide_info_widget_for_single_mode(self): + if (not self._batch_mode): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.get_toolbar_box().hide_info_widget() + + def _unhide_info_widget_for_single_mode(self): + if not self._batch_mode: + from jarabe.journal.journalactivity import get_journal + get_journal().update_progress(0) + + def _handle_error_alert(self, error_message, metadata): + """ + This handles any error scenarios. Examples are of entries that + display the message "Entries without a file cannot be copied." + This is kind of controller-functionl the model-function is + "self._set_error_info_alert". + """ + + if self._skip_all: + self._post_operate_per_metadata_per_action(metadata) + else: + self._set_error_info_alert(error_message, metadata) + + def _set_error_info_alert(self, error_message, metadata): + """ + This method displays the error alert. + """ + + current_len = len(self._metadata_list) + + # Only show the alert, if allowed to. + if self._show_not_completed_ops_info: + from jarabe.journal.journalactivity import get_journal + get_journal().update_confirmation_alert(_('Error'), + error_message, + self._process_error_skipping, + metadata) + else: + self._process_error_skipping(metadata, gtk.RESPONSE_OK) + + def _process_error_skipping(self, metadata, response_id): + # This sets up the decision, as to whether continue operations + # with the rest of the metadata. + if response_id == Gtk.ResponseType.CANCEL: + self._cancel_further_batch_operation_items() + + self._post_operate_per_metadata_per_action(metadata) + + def _cancel_further_batch_operation_items(self): + self._continue_operation = False + + # Optimization: + # Clear the metadata-list as well. + # This would prevent the unnecessary traversing of the + # remaining checkboxes-corresponding-to-remaining-metadata (of + # course without doing any effective action). + self._metadata_list = [] + + def _file_path_valid(self, metadata): + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + # Now, for locally mounted remote-shares, download the file. + # Note that, always download the file, to avoid the problems + # of stale-cache. + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + file_path = metadata['uid'] + filename = os.path.basename(file_path) + ip_address_or_dns_name = \ + model.extract_ip_address_or_dns_name_from_locally_mounted_remote_share_path(file_path) + + data_webdav_manager = \ + webdavmanager.get_data_webdav_manager(ip_address_or_dns_name) + metadata_webdav_manager = \ + webdavmanager.get_metadata_webdav_manager(ip_address_or_dns_name) + + # Download the preview file, if it exists. + preview_resource = \ + webdavmanager.get_resource_by_resource_key(metadata_webdav_manager, + '/webdav/.Sugar-Metadata/' + filename + '.preview') + preview_path = os.path.dirname(file_path) + '/.Sugar-Metadata/'+ filename + '.preview' + + if preview_resource is not None: + try: + preview_resource.downloadFile(preview_path, + show_progress=False, + filesize=0) + except (WebdavError, socket.error), e: + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + logging.warn(error_message) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, + _('Error')) + return False + + # If we manage to reach here, download the data file. + data_resource = \ + webdavmanager.get_resource_by_resource_key(data_webdav_manager, + '/webdav/'+ filename) + try: + data_resource.downloadFile(metadata['uid'], + show_progress=True, + filesize=int(metadata['filesize'])) + return True + except (WebdavError, socket.error), e: + # Delete the downloaded preview file, if it exists. + if os.path.exists(preview_path): + os.unlink(preview_path) + + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + logging.warn(error_message) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, + _('Error')) + return False + + file_path = model.get_file(metadata['uid']) if not file_path or not os.path.exists(file_path): logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return + error_message = _('Entries without a file cannot be copied.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Warning')) + return False + else: + return True + + def _metadata_copy_valid(self, metadata, mount_point): + self._set_bundle_installation_allowed(False) try: - model.copy(self._metadata, mount_point) - except IOError, e: - logging.exception('Error while copying the entry. %s', e.strerror) - self.emit('volume-error', - _('Error while copying the entry. %s') % e.strerror, - _('Error')) + model.copy(metadata, mount_point) + return True + except Exception, e: + logging.exception(e) + error_message = _('Error while copying the entry. %s') % e + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + finally: + self._set_bundle_installation_allowed(True) + def _metadata_write_valid(self, metadata): + operation = self._get_info_alert_title() + self._set_bundle_installation_allowed(False) -class ClipboardMenu(MenuItem): - __gtype_name__ = 'JournalClipboardMenu' + try: + model.update_only_metadata_and_preview_files_and_return_file_paths(metadata) + return True + except Exception, e: + logging.exception('Error while writing the metadata. %s', e) + error_message = _('Error occurred while %s : %s.') % \ + (operation, e,) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + finally: + self._set_bundle_installation_allowed(True) - __gsignals__ = { - 'volume-error': (GObject.SignalFlags.RUN_FIRST, None, - ([str, str])), - } + def _set_bundle_installation_allowed(self, allowed): + """ + This method serves only as a "delegating" method. + This has been done to aid easy configurability. + """ + from jarabe.journal.journalactivity import get_journal + journal = get_journal() - def __init__(self, metadata): - MenuItem.__init__(self, _('Clipboard')) + if self._batch_mode: + journal.set_bundle_installation_allowed(allowed) - self._temp_file_path = None - self._metadata = metadata - self.connect('activate', self.__copy_to_clipboard_cb) + def get_number_of_entries_to_operate_upon(self): + return len(self._immutable_metadata_list) - def __copy_to_clipboard_cb(self, menu_item): - file_path = model.get_file(self._metadata['uid']) - if not file_path or not os.path.exists(file_path): - logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return + +class BaseCopyMenuItem(MenuItem, ActionItem): + __gtype_name__ = 'JournalBaseCopyMenuItem' + + __gsignals__ = { + 'volume-error': (GObject.SignalFlags.RUN_FIRST, + None, ([str, str])), + } + + def __init__(self, metadata_list, label, show_editing_alert, + show_progress_info_alert, batch_mode, mount_point): + MenuItem.__init__(self, label) + ActionItem.__init__(self, label, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode, + auto_deselect_source_entries=False, + need_to_popup_options=False, + operate_on_deselected_entries=False, + show_not_completed_ops_info=True) + self._mount_point = mount_point + + def get_mount_point(self): + return self._mount_point + + def _get_actionable_signal(self): + return 'activate' + + def _get_editing_alert_title(self): + return _('Copy') + + def _get_editing_alert_message(self, entries_len): + return ngettext('Do you want to copy %d entry to %s?', + 'Do you want to copy %d entries to %s?', + entries_len) % (entries_len, self._label) + + def _get_editing_alert_operation(self): + return _('Copy') + + def _get_info_alert_title(self): + return _('Copying') + + def _operate(self, metadata): + from jarabe.journal.journalactivity import get_mount_point + if(model.is_mount_point_for_locally_mounted_remote_share(get_mount_point())) \ + and (model.is_mount_point_for_school_server(get_mount_point()) == True): + PassphraseDialog(self._proceed_after_receiving_passphrase, metadata) + else: + self._proceed_with_copy(metadata) + + def _proceed_after_receiving_passphrase(self, metadata, passphrase): + if metadata['passphrase'] != passphrase: + error_message = _('Passphrase does not match.') + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self._handle_single_mode_notification(error_message, _('Error')) + return False + else: + self._unhide_info_widget_for_single_mode() + GObject.idle_add(self._proceed_with_copy, metadata) + + def _proceed_with_copy(self, metadata): + return NotImplementedError + + def _post_successful_copy(self, metadata, response_id=None): + from jarabe.journal.journalactivity import get_journal, \ + get_mount_point + + if model.is_mount_point_for_locally_mounted_remote_share(get_mount_point()): + successful_downloading_message = None + + if model.is_mount_point_for_school_server(get_mount_point()) == True: + # TRANS: Do not translate the %s. + successful_downloading_message = \ + _('Your file "%s" was correctly downloaded from the School Server.' % metadata['title']) + else: + # TRANS: Do not translate the %s. + successful_downloading_message = \ + _('Your file "%s" was correctly downloaded from the Peer.' % metadata['title']) + + from jarabe.journal.journalactivity import get_journal + get_journal().update_error_alert(self._get_editing_alert_title(), + successful_downloading_message, + self._post_operate_per_metadata_per_action, + metadata) + else: + self._post_operate_per_metadata_per_action(metadata) + + +class VolumeMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, label, mount_point, + show_editing_alert, show_progress_info_alert, + batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, label, + show_editing_alert, + show_progress_info_alert, batch_mode, + mount_point) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, self._mount_point): + return False + + # This is sync-operation. Thus, call the callback. + self._post_successful_copy(metadata) + + +class ClipboardMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Clipboard'), + show_editing_alert, + show_progress_info_alert, + batch_mode, None) + self._temp_file_path_list = [] + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False clipboard = Gtk.Clipboard() clipboard.set_with_data([('text/uri-list', 0, 0)], self.__clipboard_get_func_cb, - self.__clipboard_clear_func_cb) + self.__clipboard_clear_func_cb, + metadata) - def __clipboard_get_func_cb(self, clipboard, selection_data, info, data): + def __clipboard_get_func_cb(self, clipboard, selection_data, info, + metadata): # Get hold of a reference so the temp file doesn't get deleted - self._temp_file_path = model.get_file(self._metadata['uid']) + self._temp_file_path = model.get_file(metadata['uid']) logging.debug('__clipboard_get_func_cb %r', self._temp_file_path) selection_data.set_uris(['file://' + self._temp_file_path]) - def __clipboard_clear_func_cb(self, clipboard, data): + def __clipboard_clear_func_cb(self, clipboard, metadata): # Release and delete the temp file self._temp_file_path = None + # This is async-operation; and this is the ending point. + self._post_successful_copy(metadata) + + +class DocumentsMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Documents'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.get_documents_path()) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.get_documents_path()): + return False + + # This is sync-operation. Call the post-operation now. + self._post_successful_copy(metadata) + + +class LocalSharesMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Local Shares'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.LOCAL_SHARES_MOUNT_POINT) + + def _proceed_with_copy(self, metadata): + if not self._file_path_valid(metadata): + return False + + # Attach the filesize. + file_path = model.get_file(metadata['uid']) + metadata['filesize'] = os.stat(file_path).st_size + + # Attach the current mount-point. + from jarabe.journal.journalactivity import get_mount_point + metadata['mountpoint'] = get_mount_point() + + if not self._metadata_write_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.LOCAL_SHARES_MOUNT_POINT): + return False + + # This is sync-operation. Call the post-operation now. + self._post_successful_copy(metadata) + + +class SchoolServerMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('School Server'), + show_editing_alert, + show_progress_info_alert, + batch_mode, + model.WEBDAV_MOUNT_POINT + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME) + + def _operate(self, metadata): + if not self._file_path_valid(metadata): + return False + + # If the entry is copyable, proceed with asking the + # upload-passphrase. + PassphraseDialog(self._proceed_after_receiving_passphrase, metadata) + + def _proceed_after_receiving_passphrase(self, metadata, passphrase): + self._unhide_info_widget_for_single_mode() + GObject.idle_add(self._proceed_with_uploading, metadata, + passphrase) + + def _proceed_with_uploading(self, metadata, passphrase): + # + # Attach the passphrase. + metadata['passphrase'] = passphrase + + # Attach the filesize. + file_path = model.get_file(metadata['uid']) + metadata['filesize'] = os.stat(file_path).st_size + + # Attach the current mount-point. + from jarabe.journal.journalactivity import get_mount_point, \ + get_journal + metadata['mountpoint'] = get_mount_point() + + # Attach the info of the uploader. + from jarabe.model.buddy import get_owner_instance + metadata['uploader-nick'] = get_owner_instance().props.nick + metadata['uploader-serial'] = misc.get_xo_serial() + + if not self._metadata_write_valid(metadata): + return False + + if not self._metadata_copy_valid(metadata, + model.WEBDAV_MOUNT_POINT + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME): + return False + + # TRANS: Do not translate the %s. + successful_uploading_message = \ + _('Your file "%s" was correctly uploaded to the School Server.' % metadata['title']) + get_journal().update_error_alert(self._get_editing_alert_title(), + successful_uploading_message, + self._post_successful_copy, + metadata) + class FriendsMenu(Gtk.Menu): __gtype_name__ = 'JournalFriendsMenu' @@ -401,3 +1152,116 @@ class BuddyPalette(Palette): icon=buddy_icon) # TODO: Support actions on buddies, like make friend, invite, etc. + + +class CopyMenuHelper(Gtk.Menu): + __gtype_name__ = 'JournalCopyMenuHelper' + + __gsignals__ = { + 'volume-error': (GObject.SignalFlags.RUN_FIRST, + None, ([str, str])), + } + + def insert_copy_to_menu_items(self, menu, metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode): + self._metadata_list = metadata_list + + clipboard_menu = ClipboardMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + clipboard_menu.set_image(Icon(icon_name='toolbar-edit', + icon_size=Gtk.IconSize.MENU)) + clipboard_menu.connect('volume-error', self.__volume_error_cb) + menu.append(clipboard_menu) + clipboard_menu.show() + + from jarabe.journal.journalactivity import get_mount_point + + if get_mount_point() != model.get_documents_path(): + documents_menu = DocumentsMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + documents_menu.set_image(Icon(icon_name='user-documents', + icon_size=Gtk.IconSize.MENU)) + documents_menu.connect('volume-error', self.__volume_error_cb) + menu.append(documents_menu) + documents_menu.show() + + if (model.is_school_server_present()) and \ + (not model.is_mount_point_for_locally_mounted_remote_share(get_mount_point())): + documents_menu = SchoolServerMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + documents_menu.set_image(Icon(icon_name='school-server', + icon_size=Gtk.IconSize.MENU)) + documents_menu.connect('volume-error', self.__volume_error_cb) + menu.append(documents_menu) + documents_menu.show() + + if (model.is_peer_to_peer_sharing_available()) and \ + (get_mount_point() != model.LOCAL_SHARES_MOUNT_POINT): + local_shares_menu = LocalSharesMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + local_shares_menu.set_image(Icon(icon_name='emblem-neighborhood-shared', + icon_size=Gtk.IconSize.MENU)) + local_shares_menu.connect('volume-error', self.__volume_error_cb) + menu.append(local_shares_menu) + local_shares_menu.show() + + if get_mount_point() != '/': + client = GConf.Client.get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + journal_menu = VolumeMenu(metadata_list, _('Journal'), '/', + show_editing_alert, + show_progress_info_alert, + batch_mode) + journal_menu.set_image(Icon(icon_name='activity-journal', + xo_color=color, + icon_size=Gtk.IconSize.MENU)) + journal_menu.connect('volume-error', self.__volume_error_cb) + menu.append(journal_menu) + journal_menu.show() + + volume_monitor = Gio.VolumeMonitor.get() + icon_theme = Gtk.IconTheme.get_default() + for mount in volume_monitor.get_mounts(): + if get_mount_point() == mount.get_root().get_path(): + continue + + volume_menu = VolumeMenu(metadata_list, mount.get_name(), + mount.get_root().get_path(), + show_editing_alert, + show_progress_info_alert, + batch_mode) + for name in mount.get_icon().props.names: + if icon_theme.has_icon(name): + volume_menu.set_image(Icon(icon_name=name, + icon_size=Gtk.IconSize.MENU)) + break + + volume_menu.connect('volume-error', self.__volume_error_cb) + menu.insert(volume_menu, -1) + volume_menu.show() + + def __volume_error_cb(self, menu_item, message, severity): + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + journal._volume_error_cb(menu_item, message, severity) + + +def get_copy_menu_helper(): + global _copy_menu_helper + if _copy_menu_helper is None: + _copy_menu_helper = CopyMenuHelper() + return _copy_menu_helper + + +def get_current_action_item(): + return _current_action_item diff --git a/src/jarabe/journal/processdialog.py b/src/jarabe/journal/processdialog.py new file mode 100644 index 0000000..d738303 --- /dev/null +++ b/src/jarabe/journal/processdialog.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python +# Copyright (C) 2010, Plan Ceibal +# Copyright (C) 2010, Paraguay Educa +# +# This program 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 program 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 this program. If not, see . + + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import Gdk +from gi.repository import GConf + +import logging + +from gettext import gettext as _ +from sugar3.graphics import style +from sugar3.graphics.icon import Icon +from sugar3.graphics.xocolor import XoColor + +from jarabe.journal import misc +from jarabe.model import shell +from jarabe.model import processmanagement +from jarabe.model.session import get_session_manager + +class ProcessDialog(Gtk.Window): + + __gtype_name__ = 'SugarProcessDialog' + + def __init__(self, process_script='', process_params=[], restart_after=True): + + #FIXME: Workaround limitations of Sugar core modal handling + shell_model = shell.get_model() + shell_model.set_zoom_level(shell_model.ZOOM_HOME) + + Gtk.Window.__init__(self) + + self._process_script = processmanagement.find_and_absolutize(process_script) + self._process_params = process_params + self._restart_after = restart_after + self._start_message = _('Running') + self._failed_message = _('Failed') + self._finished_message = _('Finished') + self._prerequisite_message = ('Prerequisites were not met') + + self.set_border_width(style.LINE_WIDTH) + width = Gdk.Screen.width() + height = Gdk.Screen.height() + self.set_size_request(width, height) + self.set_position(Gtk.WindowPosition.CENTER_ALWAYS) + self.set_decorated(False) + self.set_resizable(False) + self.set_modal(True) + + self._colored_box = Gtk.EventBox() + self._colored_box.modify_bg(Gtk.StateType.NORMAL, Gdk.Color.parse("white")[1]) + self._colored_box.show() + + self._vbox = Gtk.VBox() + self._vbox.set_spacing(style.DEFAULT_SPACING) + self._vbox.set_border_width(style.GRID_CELL_SIZE) + + self._colored_box.add(self._vbox) + self.add(self._colored_box) + + self._setup_information() + self._setup_progress_bar() + self._setup_options() + + self._vbox.show() + + self.connect("realize", self.__realize_cb) + + self._process_management = processmanagement.ProcessManagement() + self._process_management.connect('process-management-running', self._set_status_updated) + self._process_management.connect('process-management-started', self._set_status_started) + self._process_management.connect('process-management-finished', self._set_status_finished) + self._process_management.connect('process-management-failed', self._set_status_failed) + + def _setup_information(self): + client = GConf.Client.get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + + self._icon = Icon(icon_name='activity-journal', pixel_size=style.XLARGE_ICON_SIZE, xo_color=color) + self._icon.show() + + self._vbox.pack_start(self._icon, False, False, 0) + + self._title = Gtk.Label() + self._title.modify_fg(Gtk.StateType.NORMAL, style.COLOR_BLACK.get_gdk_color()) + self._title.set_use_markup(True) + self._title.set_justify(Gtk.Justification.CENTER) + self._title.show() + + self._vbox.pack_start(self._title, False, False, 0) + + self._message = Gtk.Label() + self._message.modify_fg(Gtk.StateType.NORMAL, style.COLOR_BLACK.get_gdk_color()) + self._message.set_use_markup(True) + self._message.set_line_wrap(True) + self._message.set_justify(Gtk.Justification.CENTER) + self._message.show() + + self._vbox.pack_start(self._message, True, True, 0) + + def _setup_options(self): + hbox = Gtk.HBox(True, 3) + hbox.show() + + icon = Icon(icon_name='dialog-ok') + + self._start_button = Gtk.Button() + self._start_button.set_image(icon) + self._start_button.set_label(_('Start')) + self._start_button.connect('clicked', self.__start_cb) + self._start_button.show() + + icon = Icon(icon_name='dialog-cancel') + + self._close_button = Gtk.Button() + self._close_button.set_image(icon) + self._close_button.set_label(_('Close')) + self._close_button.connect('clicked', self.__close_cb) + self._close_button.show() + + icon = Icon(icon_name='system-restart') + + self._restart_button = Gtk.Button() + self._restart_button.set_image(icon) + self._restart_button.set_label(_('Restart')) + self._restart_button.connect('clicked', self.__restart_cb) + self._restart_button.hide() + + hbox.add(self._start_button) + hbox.add(self._close_button) + hbox.add(self._restart_button) + + halign = Gtk.Alignment(xalign=1, yalign=0, xscale=0, yscale=0) + halign.show() + halign.add(hbox) + + self._vbox.pack_start(halign, False, False, 3) + + def _setup_progress_bar(self): + alignment = Gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0.5) + alignment.show() + + self._progress_bar = Gtk.ProgressBar() + self._progress_bar.hide() + + alignment.add(self._progress_bar) + self._vbox.pack_start(alignment, False, False, 0) + + def __realize_cb(self, widget): + self.get_window().set_accept_focus(True) + + def __close_cb(self, button): + self.destroy() + + def __start_cb(self, button): + if self._check_prerequisites(): + self._process_management.do_process([self._process_script] + self._process_params) + else: + self._set_status_failed(self, error_message=self._prerequisite_message) + + def __restart_cb(self, button): + session_manager = get_session_manager() + session_manager.logout() + + def _check_prerequisites(self): + return True + + def _set_status_started(self, model, data=None): + self._message.set_markup(self._start_message) + + self._start_button.hide() + self._close_button.hide() + + self._progress_bar.set_fraction(0.05) + self._progress_bar_handler = GObject.timeout_add(1000, self.__progress_bar_handler_cb) + self._progress_bar.show() + + def __progress_bar_handler_cb(self): + self._progress_bar.pulse() + return True + + def _set_status_updated(self, model, data): + pass + + def _set_status_finished(self, model, data=None): + self._message.set_markup(self._finished_message) + + self._progress_bar.hide() + self._start_button.hide() + + if self._restart_after: + self._restart_button.show() + else: + self._close_button.show() + + def _set_status_failed(self, model=None, error_message=''): + self._message.set_markup('%s %s' % (self._failed_message, error_message)) + + self._progress_bar.hide() + self._start_button.show() + self._close_button.show() + self._restart_button.hide() + + logging.error(error_message) + + +class VolumeBackupDialog(ProcessDialog): + + def __init__(self, volume_path): + ProcessDialog.__init__(self, 'journal-backup-volume', \ + [volume_path, misc.get_backup_identifier()], restart_after=False) + + self._resetup_information(volume_path) + + def _resetup_information(self, volume_path): + self._start_message = '%s %s. \n\n' % (_('Please wait, saving Journal content to'), volume_path) + \ + '%s' % _('Do not remove the storage device!') + + self._finished_message = _('The Journal content has been saved.') + + self._title.set_markup('%s' % _('Backup')) + + self._message.set_markup('%s %s' % (_('Journal content will be saved to'), volume_path)) + + +class VolumeRestoreDialog(ProcessDialog): + + def __init__(self, volume_path): + ProcessDialog.__init__(self, 'journal-restore-volume', \ + [volume_path, misc.get_backup_identifier()]) + + self._resetup_information(volume_path) + + def _resetup_information(self, volume_path): + self._start_message = '%s %s. \n\n' % (_('Please wait, restoring Journal content from'), volume_path) + \ + '%s' % _('Do not remove the storage device!') + + self._finished_message = _('The Journal content has been restored.') + + self._title.set_markup('%s' % _('Restore')) + + self._message.set_markup('%s %s.\n\n' % (_('Journal content will be restored from'), volume_path) + \ + '%s %s' % (_('Warning:'), _('Current Journal content will be deleted!'))) + + self._prerequisite_message = _(', please close all the running activities.') + + def _check_prerequisites(self): + return len(shell.get_model()) <= 2 diff --git a/src/jarabe/journal/volumestoolbar.py b/src/jarabe/journal/volumestoolbar.py index 1fc368e..1bf81bb 100644 --- a/src/jarabe/journal/volumestoolbar.py +++ b/src/jarabe/journal/volumestoolbar.py @@ -37,11 +37,14 @@ from sugar3.graphics.xocolor import XoColor from sugar3 import env from jarabe.journal import model -from jarabe.view.palettes import VolumePalette +from jarabe.view.palettes import JournalVolumePalette, RemoteSharePalette _JOURNAL_0_METADATA_DIR = '.olpc.store' +SHARE_TYPE_PEER = 1 +SHARE_TYPE_SCHOOL_SERVER = 2 + def _get_id(document): """Get the ID for the document in the xapian database.""" @@ -193,6 +196,17 @@ class VolumesToolbar(Gtk.Toolbar): def _set_up_volumes(self): self._set_up_documents_button() + if model.is_peer_to_peer_sharing_available(): + self._set_up_local_shares_button() + + client = GConf.Client.get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + + if model.is_school_server_present(): + self._add_remote_share_button(_('School-Server Shares'), + model.SCHOOL_SERVER_IP_ADDRESS_OR_DNS_NAME, + color, SHARE_TYPE_SCHOOL_SERVER) + volume_monitor = Gio.VolumeMonitor.get() self._mount_added_hid = volume_monitor.connect('mount-added', self.__mount_added_cb) @@ -202,12 +216,11 @@ class VolumesToolbar(Gtk.Toolbar): for mount in volume_monitor.get_mounts(): self._add_button(mount) - def _set_up_documents_button(self): - documents_path = model.get_documents_path() - if documents_path is not None: - button = DocumentsButton(documents_path) + def _set_up_directory_button(self, dir_path, icon_name, label_text): + if dir_path is not None: + button = DirectoryButton(dir_path, icon_name) button.props.group = self._volume_buttons[0] - label = glib.markup_escape_text(_('Documents')) + label = glib.markup_escape_text(label_text) button.set_palette(Palette(label)) button.connect('toggled', self._button_toggled_cb) button.show() @@ -217,6 +230,44 @@ class VolumesToolbar(Gtk.Toolbar): self._volume_buttons.append(button) self.show() + def _set_up_documents_button(self): + documents_path = model.get_documents_path() + self._set_up_directory_button(documents_path, + 'user-documents', + _('Documents')) + + def _set_up_local_shares_button(self): + local_shares_path = model.LOCAL_SHARES_MOUNT_POINT + self._set_up_directory_button(local_shares_path, + 'emblem-neighborhood-shared', + _('Local Shares')) + + def _add_remote_share_button(self, primary_text, + ip_address_or_dns_name, color, + share_type): + button = RemoteSharesButton(primary_text, ip_address_or_dns_name, + color, share_type) + button._share_type = share_type + button.props.group = self._volume_buttons[0] + + show_unmount_option = None + if share_type == SHARE_TYPE_PEER: + show_unmount_option = True + else: + show_unmount_option = False + button.set_palette(RemoteSharePalette(primary_text, + ip_address_or_dns_name, button, + show_unmount_option)) + button.connect('toggled', self._button_toggled_cb) + button.show() + + position = self.get_item_index(self._volume_buttons[-1]) + 1 + self.insert(button, position) + self._volume_buttons.append(button) + self.show() + + return button + def __mount_added_cb(self, volume_monitor, mount): self._add_button(mount) @@ -247,10 +298,26 @@ class VolumesToolbar(Gtk.Toolbar): def __volume_error_cb(self, button, strerror, severity): self.emit('volume-error', strerror, severity) - def _button_toggled_cb(self, button): - if button.props.active: + def _button_toggled_cb(self, button, force_toggle=False): + if button.props.active or force_toggle: + button.set_active(True) + from jarabe.journal.journalactivity import get_journal + journal = get_journal() + + journal.hide_alert() + journal.get_list_view()._selected_entries = 0 + journal.switch_to_editing_mode(False) + journal.get_list_view().inhibit_refresh(False) + self.emit('volume-changed', button.mount_point) + def _unmount_activated_cb(self, menu_item, mount): + logging.debug('VolumesToolbar._unmount_activated_cb: %r', mount) + mount.unmount(self.__unmount_cb) + + def __unmount_cb(self, source, result): + logging.debug('__unmount_cb %r %r', source, result) + def _get_button_for_mount(self, mount): mount_point = mount.get_root().get_path() for button in self.get_children(): @@ -259,6 +326,13 @@ class VolumesToolbar(Gtk.Toolbar): logging.error('Couldnt find button with mount_point %r', mount_point) return None + def _get_button_for_mount_point(self, mount_point): + for button in self.get_children(): + if button.mount_point == mount_point: + return button + logging.error('Couldnt find button with mount_point %r', mount_point) + return None + def _remove_button(self, mount): button = self._get_button_for_mount(mount) self._volume_buttons.remove(button) @@ -268,10 +342,33 @@ class VolumesToolbar(Gtk.Toolbar): if len(self.get_children()) < 2: self.hide() + def _remove_remote_share_button(self, ip_address_or_dns_name): + for button in self.get_children(): + if type(button) == RemoteSharesButton and \ + button.mount_point == (model.WEBDAV_MOUNT_POINT + ip_address_or_dns_name): + self._volume_buttons.remove(button) + self.remove(button) + + from jarabe.journal.webdavmanager import \ + unmount_share_from_backend + unmount_share_from_backend(ip_address_or_dns_name) + + self.get_children()[0].props.active = True + + if len(self.get_children()) < 2: + self.hide() + break; + def set_active_volume(self, mount): button = self._get_button_for_mount(mount) button.props.active = True + def get_journal_button(self): + return self._volume_buttons[0] + + def get_button_toggled_cb(self): + return self._button_toggled_cb + class BaseButton(RadioToolButton): __gsignals__ = { @@ -291,23 +388,36 @@ class BaseButton(RadioToolButton): def _drag_data_received_cb(self, widget, drag_context, x, y, selection_data, info, timestamp): + # Disallow copying to mounted-shares for peers. + if (model.is_mount_point_for_locally_mounted_remote_share(self.mount_point)) and \ + (model.is_mount_point_for_peer_share(self.mount_point)): + from jarabe.journal.journalactivity import get_journal + + journal = get_journal() + journal._show_alert(_('Entries cannot be copied to Peer-Shares.'), _('Error')) + return + object_id = selection_data.data metadata = model.get(object_id) - file_path = model.get_file(metadata['uid']) - if not file_path or not os.path.exists(file_path): - logging.warn('Entries without a file cannot be copied.') - self.emit('volume-error', - _('Entries without a file cannot be copied.'), - _('Warning')) - return - try: - model.copy(metadata, self.mount_point) - except IOError, e: - logging.exception('Error while copying the entry. %s', e.strerror) - self.emit('volume-error', - _('Error while copying the entry. %s') % e.strerror, - _('Error')) + from jarabe.journal.palettes import CopyMenu, get_copy_menu_helper + copy_menu_helper = get_copy_menu_helper() + + dummy_copy_menu = CopyMenu() + copy_menu_helper.insert_copy_to_menu_items(dummy_copy_menu, + [metadata], + False, + False, + False) + + # Now, activate the menuitem, whose mount-point matches the + # mount-point of the button, upon whom the item has been + # dragged. + children_menu_items = dummy_copy_menu.get_children() + for child in children_menu_items: + if child.get_mount_point() == self.mount_point: + child.activate() + return class VolumeButton(BaseButton): @@ -335,7 +445,7 @@ class VolumeButton(BaseButton): self.props.xo_color = color def create_palette(self): - palette = VolumePalette(self._mount) + palette = JournalVolumePalette(self._mount) #palette.props.invoker = FrameWidgetInvoker(self) #palette.set_group_id('frame') return palette @@ -386,13 +496,35 @@ class JournalButtonPalette(Palette): {'free_space': free_space / (1024 * 1024)} -class DocumentsButton(BaseButton): +class DirectoryButton(BaseButton): - def __init__(self, documents_path): - BaseButton.__init__(self, mount_point=documents_path) + def __init__(self, dir_path, icon_name): + BaseButton.__init__(self, mount_point=dir_path) + self._mount = dir_path - self.props.icon_name = 'user-documents' + self.props.icon_name = icon_name client = GConf.Client.get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) self.props.xo_color = color + + +class RemoteSharesButton(BaseButton): + + def __init__(self, primary_text, ip_address_or_dns_name, color, + share_type): + BaseButton.__init__(self, mount_point=(model.WEBDAV_MOUNT_POINT + ip_address_or_dns_name)) + + self._primary_text = primary_text + self._ip_address_or_dns_name = ip_address_or_dns_name + + if share_type == SHARE_TYPE_PEER: + self.props.icon_name = 'emblem-neighborhood-shared' + elif share_type == SHARE_TYPE_SCHOOL_SERVER: + self.props.icon_name = 'school-server' + self.props.xo_color = color + + def create_palette(self): + palette = RemoteSharePalette(self._primary_text, self._ip_address_or_dns_name, + self, True) + return palette diff --git a/src/jarabe/journal/webdavmanager.py b/src/jarabe/journal/webdavmanager.py new file mode 100644 index 0000000..6936239 --- /dev/null +++ b/src/jarabe/journal/webdavmanager.py @@ -0,0 +1,312 @@ +from gettext import gettext as _ + +from gi.repository import GObject + +import logging +import os +import sys + +import simplejson +import shutil + +from webdav.Connection import AuthorizationError, WebdavError +from webdav.WebdavClient import CollectionStorer + +def get_key_from_resource(resource): + return resource.path + +class WebDavUrlManager(GObject.GObject): + """ + This class holds all data, relevant to a WebDavUrl. + + One thing must be noted, that a valid WebDavUrl is the one which + may contain zero or more resources (files), or zero or more + collections (directories). + + Thus, following are valid WebDavUrls :: + + dav://1.2.3.4/webdav + dav://1.2.3.4/webdav/dir_1 + dav://1.2.3.4/webdav/dir_1/dir_2 + + but following are not :: + + dav://1.2.3.4/webdav/a.txt + dav://1.2.3.4/webdav/dir_1/b.jpg + dav://1.2.3.4/webdab/dir_1/dir_2/c.avi + """ + + def __init__(self, WebDavUrl, username, password): + self._WebDavUrl = WebDavUrl + self._username = username + self._password = password + + def _get_key_from_resource(self, resource): + return resource.path.encode(sys.getfilesystemencoding()) + + def _get_number_of_collections(self): + return len(self._remote_webdav_share_collections) + + def _get_root(self): + return self._root + + def _get_resources_dict(self): + return self._remote_webdav_share_resources + + def _get_collections_dict(self): + return self._remote_webdav_share_collections + + def _get_resource_by_key(self, key): + if key in self._remote_webdav_share_resources.keys(): + return self._remote_webdav_share_resources[key]['resource'] + return None + + def _add_or_replace_resource_by_key(self, key, resource): + self._remote_webdav_share_resources[key] = {} + self._remote_webdav_share_resources[key]['resource'] = resource + + def _get_metadata_list(self): + metadata_list = [] + for key in self._remote_webdav_share_resources.keys(): + metadata_list.append(self._remote_webdav_share_resources[key]['metadata']) + return metadata_list + + def _get_live_properties(self, resource_key): + resource_container = self._remote_webdav_share_resources[resource_key] + return resource_container['webdav-properties'] + + def _fetch_resources_and_collections(self): + webdavConnection = CollectionStorer(self._WebDavUrl, validateResourceNames=False) + self._root = webdavConnection + + authFailures = 0 + while authFailures < 2: + try: + self._remote_webdav_share_resources = {} + self._remote_webdav_share_collections = {} + + try: + self._collection_contents = webdavConnection.getCollectionContents() + for resource, properties in self._collection_contents: + try: + key = self._get_key_from_resource(resource) + selected_dict = None + + if properties.getResourceType() == 'resource': + selected_dict = self._remote_webdav_share_resources + else: + selected_dict = self._remote_webdav_share_collections + + selected_dict[key] = {} + selected_dict[key]['resource'] = resource + selected_dict[key]['webdav-properties'] = properties + except UnicodeEncodeError: + print("Cannot encode resource path or properties.") + + return True + + except WebdavError, e: + # Note that, we need to deal with all errors, + # except "AuthorizationError", as that is not + # really an error from our perspective. + if not type(e) == AuthorizationError: + from jarabe.journal.journalactivity import get_journal + + from jarabe.journal.palettes import USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + error_message = USER_FRIENDLY_GENERIC_WEBDAV_ERROR_MESSAGE + get_journal()._volume_error_cb(None, error_message,_('Error')) + + # Re-raise this error. + # Note that since this is not an + # "AuthorizationError", this will not be caught + # by the outer except-block. Instead, it will + # navigate all the way back up, and will report + # the error in the enclosing except block. + raise e + + else: + # If this indeed is an Authorization Error, + # re-raise it, so that it is caught by the outer + # "except" block. + raise e + + + except AuthorizationError, e: + if self._username is None or self._password is None: + raise Exception("WebDav username or password is None. Please specify appropriate values.") + + if e.authType == "Basic": + webdavConnection.connection.addBasicAuthorization(self._username, self._password) + elif e.authType == "Digest": + info = parseDigestAuthInfo(e.authInfo) + webdavConnection.connection.addDigestAuthorization(self._username, self._password, realm=info["realm"], qop=info["qop"], nonce=info["nonce"]) + else: + raise + authFailures += 1 + + return False + +webdav_manager = {} + +def get_data_webdav_manager(ip_address_or_dns_name): + return webdav_manager[ip_address_or_dns_name]['data'] + + +def get_metadata_webdav_manager(ip_address_or_dns_name): + return webdav_manager[ip_address_or_dns_name]['metadata'] + + +def get_resource_by_resource_key(root_webdav, key): + resources_dict = root_webdav._get_resources_dict() + if key in resources_dict.keys(): + resource_dict = resources_dict[key] + resource = resource_dict['resource'] + return resource + return None + + +def add_resource_by_resource_key(root_webdav, key, + content_file_path): + root = root_webdav._get_root() + + resource = root.addResource(key) + + # Procure the resource-lock. + lockToken = resource.lock('olpc') + + input_stream = open(content_file_path) + + # Now, upload the data; but it's necessary to enclose this in a + # try-except-finally block here, since we need to close the + # input-stream, whatever may happen. + try: + resource.uploadFile(input_stream, lockToken) + root_webdav._add_or_replace_resource_by_key(key, resource) + except Exception, e: + logging.exception(e) + resource.delete(lockToken) + raise e + else: + resource.unlock(lockToken) + finally: + input_stream.close() + + +def get_remote_webdav_share_metadata(ip_address_or_dns_name): + protocol = 'davs://' + root_webdav_url = '/webdav' + complete_root_url = protocol + ip_address_or_dns_name + root_webdav_url + + root_webdav = WebDavUrlManager(complete_root_url, 'test', 'olpc') + if root_webdav._fetch_resources_and_collections() is False: + # Return empty metadata list. + return [] + + # Keep reference to the "WebDavUrlManager", keyed by IP-Address. + global webdav_manager + webdav_manager[ip_address_or_dns_name] = {} + webdav_manager[ip_address_or_dns_name]['data'] = root_webdav + + + # Assert that the number of collections is only one at this url + # (i.e. only ".Sugar-Metadata" is present). + assert root_webdav._get_number_of_collections() == 1 + + root_sugar_metadata_url = root_webdav_url + '/.Sugar-Metadata' + + complete_root_sugar_metadata_url = protocol + ip_address_or_dns_name + root_sugar_metadata_url + root_webdav_sugar_metadata = WebDavUrlManager(complete_root_sugar_metadata_url, 'test', 'olpc') + if root_webdav_sugar_metadata._fetch_resources_and_collections() is False: + # Return empty metadata list. + return [] + + webdav_manager[ip_address_or_dns_name]['metadata'] = root_webdav_sugar_metadata + + # assert that the number of collections is zero at this url. + assert root_webdav_sugar_metadata._get_number_of_collections() == 0 + + # Now. associate sugar-metadata with each of the "root-webdav" + # resource. + root_webdav_resources = root_webdav._get_resources_dict() + root_webdav_sugar_metadata_resources = root_webdav_sugar_metadata._get_resources_dict() + + # Prepare the metadata-download folder. + downloaded_data_root_dir = '/tmp/' + ip_address_or_dns_name + downloaded_metadata_file_dir = downloaded_data_root_dir + '/.Sugar-Metadata' + if os.path.isdir(downloaded_data_root_dir): + shutil.rmtree(downloaded_data_root_dir) + os.makedirs(downloaded_metadata_file_dir) + + metadata_list = [] + + # Note that the presence of a resource in the metadata directory, + # is the only assurance of the entry (and its constituents) being + # present in entirety. Thus, always proceed taking the metadata as + # the "key". + for root_webdav_sugar_metadata_resource_name in root_webdav_sugar_metadata_resources.keys(): + """ + root_webdav_sugar_metadata_resource_name is of the type :: + + /webdav/.Sugar-Metadata/a.txt.metadata, OR + /webdav/.Sugar-Metadata/a.txt.preview + """ + + # If this is a "preview" resource, continue forward, as we only + # want the metadata list. The "preview" resources are anyways + # already present in the manager DS. + if root_webdav_sugar_metadata_resource_name.endswith('.preview'): + continue + + split_tokens_array = root_webdav_sugar_metadata_resource_name.split('/') + + # This will provide us with "a.txt.metadata" + sugar_metadata_basename = split_tokens_array[len(split_tokens_array) - 1] + + # This will provide us with "a.txt" + basename = sugar_metadata_basename[0:sugar_metadata_basename.index('.metadata')] + + downloaded_metadata_file_path = downloaded_metadata_file_dir + '/' + sugar_metadata_basename + metadata_resource = \ + root_webdav_sugar_metadata._get_resource_by_key(root_webdav_sugar_metadata_resource_name) + metadata_resource.downloadFile(downloaded_metadata_file_path) + + + # We need to download the preview-file as well at this stage, + # so that it can be shown in the expanded entry. + downloaded_preview_file_path = downloaded_metadata_file_dir + \ + '/' + basename + '.preview' + root_webdav_sugar_preview_resource_name = \ + root_webdav_sugar_metadata_resource_name[0:root_webdav_sugar_metadata_resource_name.index('.metadata')] + \ + '.preview' + preview_resource = \ + root_webdav_sugar_metadata._get_resource_by_key(root_webdav_sugar_preview_resource_name) + if preview_resource is not None: + preview_resource.downloadFile(downloaded_preview_file_path) + + + file_pointer = open(downloaded_metadata_file_path) + metadata = eval(file_pointer.read()) + file_pointer.close() + + # Fill in the missing metadata properties. + # Note that the file is not physically present. + metadata['uid'] = downloaded_data_root_dir + '/' + basename + metadata['creation_time'] = metadata['timestamp'] + + # Now, write this to the metadata-file, so that + # webdav-properties get gelled into sugar-metadata. + file_pointer = open(downloaded_metadata_file_path, 'w') + file_pointer.write(simplejson.dumps(metadata)) + file_pointer.close() + + metadata_list.append(metadata) + + return metadata_list + + +def is_remote_webdav_loaded(ip_address_or_dns_name): + return ip_address_or_dns_name in webdav_manager.keys() + + +def unmount_share_from_backend(ip_address_or_dns_name): + del webdav_manager[ip_address_or_dns_name] diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am index 2fc6b1c..d40fb8d 100644 --- a/src/jarabe/model/Makefile.am +++ b/src/jarabe/model/Makefile.am @@ -12,6 +12,7 @@ sugar_PYTHON = \ neighborhood.py \ network.py \ notifications.py \ + processmanagement.py \ shell.py \ screen.py \ session.py \ diff --git a/src/jarabe/model/neighborhood.py b/src/jarabe/model/neighborhood.py index 85c35c9..0712073 100644 --- a/src/jarabe/model/neighborhood.py +++ b/src/jarabe/model/neighborhood.py @@ -427,7 +427,8 @@ class _Account(GObject.GObject): def __buddy_info_updated_cb(self, handle, properties): logging.debug('_Account.__buddy_info_updated_cb %r', handle) - self.emit('buddy-updated', self._buddy_handles[handle], properties) + if handle in self._buddy_handles: + self.emit('buddy-updated', self._buddy_handles[handle], properties) def __current_activity_changed_cb(self, contact_handle, activity_id, room_handle): @@ -929,6 +930,9 @@ class Neighborhood(GObject.GObject): if 'key' in properties: buddy.props.key = properties['key'] + if 'ip4-address' in properties: + buddy.props.ip_address = properties['ip4-address'] + nick_key = CONNECTION_INTERFACE_ALIASING + '/alias' if nick_key in properties: buddy.props.nick = properties[nick_key] diff --git a/src/jarabe/model/network.py b/src/jarabe/model/network.py index 53e170a..c60f6af 100644 --- a/src/jarabe/model/network.py +++ b/src/jarabe/model/network.py @@ -492,6 +492,7 @@ class Settings(object): self.connection = ConnectionSettings() self.ip4_config = None self.wireless_security = None + self.wpa_eap_setting = None if wireless_cfg is not None: self.wireless = wireless_cfg @@ -507,6 +508,8 @@ class Settings(object): self.wireless_security.get_dict() if self.ip4_config is not None: settings['ipv4'] = self.ip4_config.get_dict() + if self.wpa_eap_setting is not None: + settings['802-1x'] = self.wpa_eap_setting return settings @@ -907,12 +910,14 @@ def activate_connection_by_path(connection, device_o, error_handler=error_handler) -def add_and_activate_connection(device_o, settings, specific_object): +def add_and_activate_connection(device_o, settings, specific_object, + reply_handler=_add_and_activate_reply_cb, + error_handler=_add_and_activate_error_cb): manager = get_manager() manager.AddAndActivateConnection(settings.get_dict(), device_o, specific_object, - reply_handler=_add_and_activate_reply_cb, - error_handler=_add_and_activate_error_cb) + reply_handler=reply_handler, + error_handler=error_handler) def _migrate_old_wifi_connections(): diff --git a/src/jarabe/model/processmanagement.py b/src/jarabe/model/processmanagement.py new file mode 100644 index 0000000..cb429f6 --- /dev/null +++ b/src/jarabe/model/processmanagement.py @@ -0,0 +1,120 @@ +# Copyright (C) 2010, Paraguay Educa +# Copyright (C) 2010, Plan Ceibal +# +# This program 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 2 of the License, or +# (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from gi.repository import GObject +from gi.repository import Gio + +import os +import glib + + +from sugar import env +from gettext import gettext as _ + +BYTES_TO_READ = 100 + +class ProcessManagement(GObject.GObject): + + __gtype_name__ = 'ProcessManagement' + + __gsignals__ = { + 'process-management-running' : (GObject.SignalFlags.RUN_FIRST, None, ([str])), + 'process-management-started' : (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'process-management-finished' : (GObject.SignalFlags.RUN_FIRST, None, ([])), + 'process-management-failed' : (GObject.SignalFlags.RUN_FIRST, None, ([str])) + } + + def __init__(self): + GObject.GObject.__init__(self) + self._running = False + + def do_process(self, cmd): + self._run_cmd_async(cmd) + + def _report_process_status(self, stream, result, user_data=None): + data = stream.read_finish(result) + + if data != 0: + self.emit('process-management-running', data) + stream.read_async([], + BYTES_TO_READ, + GObject.PRIORITY_LOW, + None, + self._report_process_status, + None) + + def _report_process_error(self, stream, result, concat_err=''): + data = stream.read_finish(result) + concat_err = concat_err + data + + if data != 0: + self.emit('process-management-failed', concat_err) + else: + stream.read_async([], + BYTES_TO_READ, + GObject.PRIORITY_LOW, + None, + self._report_process_error, + concat_err) + + def _notify_error(self, stderr): + stdin_stream = Gio.UnixInputStream(fd=stderr, close_fd=True) + stdin_stream.read_async([], + BYTES_TO_READ, + GObject.PRIORITY_LOW, + None, + self._report_process_error, + None) + + def _notify_process_status(self, stdout): + stdin_stream = Gio.UnixInputStream(fd=stdout, close_fd=True) + stdin_stream.read_async([], + BYTES_TO_READ, + GObject.PRIORITY_LOW, + None, + self._report_process_status, + None) + + def _run_cmd_async(self, cmd): + if self._running == False: + try: + pid, stdin, stdout, stderr = glib.spawn_async(cmd, flags=glib.SPAWN_DO_NOT_REAP_CHILD, standard_output=True, standard_error=True) + GObject.child_watch_add(pid, _handle_process_end, (self, stderr)) + except Exception: + self.emit('process-management-failed', _("Error - Call process: ") + str(cmd)) + else: + self._notify_process_status(stdout) + self._running = True + self.emit('process-management-started') + +def _handle_process_end(pid, condition, (myself, stderr)): + myself._running = False + + if os.WIFEXITED(condition) and\ + os.WEXITSTATUS(condition) == 0: + myself.emit('process-management-finished') + else: + myself._notify_error(stderr) + +def find_and_absolutize(script_name): + paths = env.os.environ['PATH'].split(':') + for path in paths: + looking_path = path + '/' + script_name + if env.os.path.isfile(looking_path): + return looking_path + + return None diff --git a/src/jarabe/view/buddymenu.py b/src/jarabe/view/buddymenu.py index d17f4ff..93790fd 100644 --- a/src/jarabe/view/buddymenu.py +++ b/src/jarabe/view/buddymenu.py @@ -73,6 +73,18 @@ class BuddyMenu(Palette): self.menu.append(menu_item) menu_item.show() + remote_share_menu_item = None + from jarabe.journal import webdavmanager + if not webdavmanager.is_remote_webdav_loaded(self._buddy.props.ip_address): + remote_share_menu_item = MenuItem(_('Access Share'), 'list-add') + remote_share_menu_item.connect('activate', self._access_share_cb) + else: + remote_share_menu_item = MenuItem(_('Unmount Share'), 'list-remove') + remote_share_menu_item.connect('activate', self.__unmount_cb) + + self.menu.append(remote_share_menu_item) + remote_share_menu_item.show() + self._invite_menu = MenuItem('') self._invite_menu.connect('activate', self._invite_friend_cb) self.menu.append(self._invite_menu) diff --git a/src/jarabe/view/palettes.py b/src/jarabe/view/palettes.py index 10844ea..bbbf822 100644 --- a/src/jarabe/view/palettes.py +++ b/src/jarabe/view/palettes.py @@ -1,4 +1,6 @@ # Copyright (C) 2008 One Laptop Per Child +# Copyright (C) 2010, Plan Ceibal +# Copyright (C) 2010, Paraguay Educa # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -33,6 +35,7 @@ from sugar3.graphics import style from sugar3.graphics.xocolor import XoColor from sugar3.activity.i18n import pgettext +from jarabe.journal.processdialog import VolumeBackupDialog, VolumeRestoreDialog from jarabe.model import shell from jarabe.view.viewsource import setup_view_source from jarabe.journal import misc @@ -268,3 +271,97 @@ class VolumePalette(Palette): self._progress_bar.props.fraction = fraction self._free_space_label.props.label = _('%(free_space)d MB Free') % \ {'free_space': free_space / (1024 * 1024)} + + +class RemoteSharePalette(Palette): + def __init__(self, primary_text, ip_address_or_dns_name, button, + show_unmount_option): + Palette.__init__(self, label=primary_text) + self._button = button + self._ip_address_or_dns_name = ip_address_or_dns_name + + self.props.secondary_text = \ + glib.markup_escape_text(self._ip_address_or_dns_name) + + vbox = Gtk.VBox() + self.set_content(vbox) + vbox.show() + + self.connect('popup', self.__popup_cb) + + menu_item = PaletteMenuItem(pgettext('Share', _('Reload'))) + icon = Icon(icon_name='system-restart', icon_size=Gtk.IconSize.MENU) + menu_item.set_image(icon) + icon.show() + + menu_item.connect('activate', self.__reload_remote_share) + vbox.add(menu_item) + menu_item.show() + + + if show_unmount_option == True: + menu_item = PaletteMenuItem(pgettext('Share', 'Unmount')) + icon = Icon(icon_name='media-eject', icon_size=gtk.ICON_SIZE_MENU) + menu_item.set_image(icon) + icon.show() + + menu_item.connect('activate', self.__unmount_activate_cb) + vbox.add(menu_item) + menu_item.show() + + def __reload_remote_share(self, menu_item): + from jarabe.journal.journalactivity import get_journal + get_journal().hide_alert() + get_journal().get_list_view().refresh() + + def __unmount_activate_cb(self, menu_item): + from jarabe.journal.journalactivity import get_journal + + singleton_volumes_toolbar = get_journal().get_volumes_toolbar() + singleton_volumes_toolbar._remove_remote_share_button(self._ip_address_or_dns_name) + + def __popup_cb(self, palette): + pass + + + + +class JournalVolumePalette(VolumePalette): + + __gtype_name__ = 'JournalVolumePalette' + + def __init__(self, mount): + VolumePalette.__init__(self, mount) + + journal_separator = gtk.SeparatorMenuItem() + journal_separator.show() + + self.menu.prepend(journal_separator) + + icon = Icon(icon_name='transfer-from', icon_size=gtk.ICON_SIZE_MENU) + icon.show() + + menu_item_journal_restore = MenuItem(_('Restore Journal')) + menu_item_journal_restore.set_image(icon) + menu_item_journal_restore.connect('activate', self.__journal_restore_activate_cb, mount.get_root().get_path()) + menu_item_journal_restore.show() + + self.menu.prepend(menu_item_journal_restore) + + icon = Icon(icon_name='transfer-to', icon_size=gtk.ICON_SIZE_MENU) + icon.show() + + menu_item_journal_backup = MenuItem(_('Backup Journal')) + menu_item_journal_backup.set_image(icon) + menu_item_journal_backup.connect('activate', self.__journal_backup_activate_cb, mount.get_root().get_path()) + menu_item_journal_backup.show() + + self.menu.prepend(menu_item_journal_backup) + + def __journal_backup_activate_cb(self, menu_item, mount_path): + dialog = VolumeBackupDialog(mount_path) + dialog.show() + + def __journal_restore_activate_cb(self, menu_item, mount_path): + dialog = VolumeRestoreDialog(mount_path) + dialog.show() diff --git a/src/jarabe/view/pulsingicon.py b/src/jarabe/view/pulsingicon.py index 81e2e03..652e22e 100644 --- a/src/jarabe/view/pulsingicon.py +++ b/src/jarabe/view/pulsingicon.py @@ -90,12 +90,23 @@ class PulsingIcon(Icon): self._pulse_color = None self._paused = False self._pulsing = False + self._timeout = 0 + self._pulsing_sid = None Icon.__init__(self, **kwargs) self._palette = None self.connect('destroy', self.__destroy_cb) + def set_timeout(self, timeout): + self._timeout = timeout + + def get_timeout(self): + return self._timeout + + timeout = GObject.property( + type=int, getter=get_timeout, setter=set_timeout) + def set_pulse_color(self, pulse_color): self._pulse_color = pulse_color self._pulser.update() @@ -142,10 +153,20 @@ class PulsingIcon(Icon): type=bool, default=False, getter=get_paused, setter=set_paused) def set_pulsing(self, pulsing): + if self._pulsing == pulsing: + return + + if self._pulsing_sid is not None: + GObject.source_remove(self._pulsing_sid) + self._pulsing_sid = None + self._pulsing = pulsing if self._pulsing: self._pulser.start(restart=True) + if self.props.timeout > 0: + self._pulsing_sid = GObject.timeout_add_seconds( + self.props.timeout, self.__timeout_cb) else: self._pulser.stop() @@ -165,6 +186,9 @@ class PulsingIcon(Icon): palette = property(_get_palette, _set_palette) + def __timeout_cb(self): + self.props.pulsing = False + def __destroy_cb(self, icon): self._pulser.stop() if self._palette is not None: diff --git a/src/webdav/Condition.py b/src/webdav/Condition.py new file mode 100644 index 0000000..76acf94 --- /dev/null +++ b/src/webdav/Condition.py @@ -0,0 +1,475 @@ +# pylint: disable-msg=R0921,W0704,R0901,W0511,R0201 +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +This module contains classes for creating a search condition according to the DASL draft. +The classes will output the WHERE part of a search request to a WebDAV server. + +Instances of the classes defined in this module form a tree data structure which represents +a search condition. This tree is made up of AND-nodes, OR-nodes, Operator- and comparison- +nodes and from property (i.e. variable) and constant leaf nodes. +""" + + +import types +from time import strftime +from calendar import timegm +from rfc822 import formatdate + +from webdav.Constants import NS_DAV, PROP_LAST_MODIFIED, DATE_FORMAT_ISO8601 + + +__version__ = "$Revision$"[11:-2] + + +class ConditionTerm(object): + """ + This is the abstact base class for all condition terms. + """ + def __init__(self): + pass + + def toXML(self): + """ + Abstact method which return a XML string which can be passed to a WebDAV server + for a search condition. + """ + raise NotImplementedError + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + """ + Abstact method for temporary workaround for Tamino's absense of the like-operator. + This method shall filter the given result set for those resources which match + all Contains-trems. + """ + return resultSet + # end of workaround + + +class IsCollectionTerm(ConditionTerm): + """ Leaf condition. Checks if the matching resources are collections. """ + + def __init__(self): + """ Constructor. """ + + ConditionTerm.__init__(self) + + def toXML(self): + """ + Returns XML encoding. + """ + + return "" + + +class Literal(ConditionTerm): + """ + A leaf class for condition expressions representing a constant value. + """ + def __init__(self, literal): + ConditionTerm.__init__(self) + self.literal = literal + + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + self.literal + "" + + +class UnaryTerm(ConditionTerm): + """ + Base class of all nodes with a single child node. + """ + def __init__(self, child): + ConditionTerm.__init__(self) + self.child = child + + def toXML(self): + ''' + Returns XML encoding. + ''' + return self.child.toXML() + + +class BinaryTerm(ConditionTerm): + """ + Base class of all nodes with two child nodes + """ + def __init__(self, left, right): + ConditionTerm.__init__(self) + self.left = left + self.right = right + + def toXML(self): + ''' + Returns XML encoding. + ''' + return self.left.toXML() + self.right.toXML() + +class TupleTerm(ConditionTerm): + """ + Base class of all nodes with multiple single child nodes. + """ + def __init__(self, terms): + ConditionTerm.__init__(self) + self.terms = terms + + def addTerm(self, term): + ''' + Removes a term. + + @param term: term to add + ''' + self.terms.append(term) + + def removeTerm(self, term): + ''' + Adds a term. + + @param term: term to remove + ''' + try: + self.terms.remove(term) + except ValueError: + pass + + def toXML(self): + ''' + Returns XML encoding. + ''' + result = "" + for term in self.terms: + result += term.toXML() + return result + + +class AndTerm(TupleTerm): + """ + This class represents and logical AND-condition with multiple sub terms. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + TupleTerm.toXML(self) + "" + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + ''' + Filters the given result set. This is a TAMINO WebDav server workaround + for the missing 'like' tag. + + @param resultSet: the result set that needs to be filtered. + ''' + for term in self.terms: + filtered = term.postFilter(resultSet) + resultSet = filtered + return resultSet + # end of workaround + +class OrTerm(TupleTerm): + """ + This class represents and logical OR-condition with multiple sub terms. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + TupleTerm.toXML(self) + "" + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + ''' + Filters the given result set. This is a TAMINO WebDav server workaround + for the missing 'like' tag. + + @param resultSet: the result set that needs to be filtered. + ''' + raise NotImplementedError + + +class NotTerm(UnaryTerm): + """ + This class represents a negation term for the contained sub term. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + # start Tamino workaround for missing like-op: + if isinstance(self.child, ContainsTerm): + return "" + # end of workaround + return "" + UnaryTerm.toXML(self) + "" + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + ''' + Filters the given result set. This is a TAMINO WebDav server workaround + for the missing 'like' tag. + + @param resultSet: the result set that needs to be filtered. + ''' + if isinstance(self.child, ContainsTerm): + self.child.negate = 1 + # TODO: pass on filter + return self.child.postFilter(resultSet) + + +class ExistsTerm(UnaryTerm): + """ + Nodes of this class must have a single child with tuple type (of len 2) representing a + WebDAV property. + This leaf term evaluates to true if the (child) property exists. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return '<%s' % self.child + ' />' + +class ContentContainsTerm(UnaryTerm): + """ + This class can be used to search for a given phrase in resources' contents. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + self.child + "" + + + +class BinaryRelationTerm(BinaryTerm): + """ + This is the abstact base class for the following relation operands. + """ + def __init__(self, left, right): + BinaryTerm.__init__(self, left, right) + if isinstance(self.left, types.StringType): # Must be namespace + name pair + self.left = ('DAV:', self.left) + if not isinstance(self.right, Literal): + self.right = Literal(self.right) # Must be Literal instance + + def toXML(self): + ''' + Returns XML encoding. + ''' + ## TODO: extract name space and create shortcut for left element + return '<%s />' % self.left + self.right.toXML() + + +class StringRelationTerm(BinaryRelationTerm): + """ + This is the abstact base class for the following string relation classes. + """ + def __init__(self, left, right, caseless=None): + """ + @param left: webdav property (namespace, name) + @param right: string/unicode literal + qparam caseless: 1 for case sensitive comparison + """ + BinaryRelationTerm.__init__(self, left, Literal(right)) + self.caseless = caseless + if self.caseless: + self.attrCaseless = "yes" + else: + self.attrCaseless = "no" + +class NumberRelationTerm(BinaryRelationTerm): + """ + This is the abstact base class for the following number comparison classes. + """ + def __init__(self, left, right): + """ + @param left: webdav property (namespace, name) + @param right: constant number + """ + ## TODO: implemet typed literal + BinaryRelationTerm.__init__(self, left, Literal(str(right))) + +class DateRelationTerm(BinaryRelationTerm): + """ + This is the abstact base class for the following date comparison classes. + """ + def __init__(self, left, right): + """ + @param left: webdav property (namespace, name) + @param right: string literal containing a date in ISO8601 format + """ + ## TODO: implemet typed literal + assert len(right) == 9, "No time is specified for literal: " + str(right) + BinaryRelationTerm.__init__(self, left, right) + if self.left == (NS_DAV, PROP_LAST_MODIFIED): + rfc822Time = formatdate(timegm(right)) # must not use locale setting + self.right = Literal(rfc822Time) + else: + self.right = Literal(strftime(DATE_FORMAT_ISO8601, right)) + + +class MatchesTerm(StringRelationTerm): + """ + Nodes of this class evaluate to true if the (child) property's value matches the (child) string. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return '' % self.attrCaseless + StringRelationTerm.toXML(self) + "" + +class ContainsTerm(StringRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) property's value contains the + (right child) string. + """ + def __init__(self, left, right, isTaminoWorkaround=False): + right = unicode(right) + StringRelationTerm.__init__(self, left, "%" + right + "%") + # Tamino workaround: operator like is not yet implemented: + self.negate = 0 + self.isTaminoWorkaround = isTaminoWorkaround + + def toXML(self): + ''' + Returns XML encoding. + ''' + # Tamino workaround: operator like is not yet implemented: + # Produce a is-defined-condition instead + if self.isTaminoWorkaround: + return "<%s" % self.left + " />" + else: + return '' % self.attrCaseless + StringRelationTerm.toXML(self) + "" + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + ''' + Filters the given result set. This is a TAMINO WebDav server workaround + for the missing 'like' tag. + + @param resultSet: the result set that needs to be filtered. + ''' + newResult = {} + word = self.right.literal[1:-1] # remove leading and trailing '%' characters (see __init__()) + for url, properties in resultSet.items(): + value = properties.get(self.left) + if self.negate: + if not value or value.textof().find(word) < 0: + newResult[url] = properties + else: + if value and value.textof().find(word) >= 0: + newResult[url] = properties + return newResult + # end of workaround + +class IsEqualTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is equal + to the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + NumberRelationTerm.toXML(self) + "" + +class IsGreaterTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is greater + than the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + NumberRelationTerm.toXML(self) + "" + +class IsGreaterOrEqualTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is greater + than or equal to the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + NumberRelationTerm.toXML(self) + "" + +class IsSmallerTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is less + than the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + NumberRelationTerm.toXML(self) + "" + +class IsSmallerOrEqualTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is less + than or equal to the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + NumberRelationTerm.toXML(self) + "" + + +class OnTerm(DateRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) property's value is a date + equal to the (right child) date. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + DateRelationTerm.toXML(self) + "" + +class AfterTerm(DateRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) property's value is a date + succeeding the (right child) date. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + DateRelationTerm.toXML(self) + "" + +class BeforeTerm(DateRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) property's value is a date + preceeding the (right child) date. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "" + DateRelationTerm.toXML(self) + "" + + + +# Simple module test +if __name__ == '__main__': + # use the example from the webdav specification + condition = AndTerm( (MatchesTerm('getcontenttype', 'image/gif'), \ + IsGreaterTerm('getcontentlength', 4096)) ) + print "Where: " + condition.toXML() diff --git a/src/webdav/Connection.py b/src/webdav/Connection.py new file mode 100644 index 0000000..33719f9 --- /dev/null +++ b/src/webdav/Connection.py @@ -0,0 +1,324 @@ +# pylint: disable-msg=W0142,W0102,R0901,R0904,E0203,E1101,C0103 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +The contained class extends the HTTPConnection class for WebDAV support. +""" + + +from httplib import HTTPConnection, CannotSendRequest, BadStatusLine, ResponseNotReady +from copy import copy +import base64 # for basic authentication +try: + import hashlib +except ImportError: # for Python 2.4 compatibility + import md5 + hashlib = md5 +import mimetypes +import os # file handling +import urllib +import types +import socket # to "catch" socket.error +from threading import RLock +try: + from uuid import uuid4 +except ImportError: # for Python 2.4 compatibility + from uuid_ import uuid4 +from xml.parsers.expat import ExpatError + +from davlib import DAV +from qp_xml import Parser + +from webdav.WebdavResponse import MultiStatusResponse, ResponseFormatError +from webdav import Constants +from webdav.logger import getDefaultLogger + + +__version__ = "$LastChangedRevision$" + + +class Connection(DAV): + """ + This class handles a connection to a WebDAV server. + This class is used internally. Client code should prefer classes + L{WebdavClient.ResourceStorer} and L{WebdavClient.CollectionStorer}. + + @author: Roland Betz + """ + + # Constants + # The following switch activates a workaround for the Tamino webdav server: + # Tamino expects URLs which are passed in a HTTP header to be Latin-1 encoded + # instead of Utf-8 encoded. + # Set this switch to zero in order to communicate with conformant servers. + blockSize = 30000 + MaxRetries = 10 + + def __init__(self, *args, **kwArgs): + DAV.__init__(self, *args, **kwArgs) + self.__authorizationInfo = None + self.logger = getDefaultLogger() + self.isConnectedToCatacomb = True + self.serverTypeChecked = False + self._lock = RLock() + + def _request(self, method, url, body=None, extra_hdrs={}): + + self._lock.acquire() + try: + # add the authorization header + extraHeaders = copy(extra_hdrs) + if self.__authorizationInfo: + + # update (digest) authorization data + if hasattr(self.__authorizationInfo, "update"): + self.__authorizationInfo.update(method=method, uri=url) + + extraHeaders["AUTHORIZATION"] = self.__authorizationInfo.authorization + + # encode message parts + body = _toUtf8(body) + url = _urlEncode(url) + for key, value in extraHeaders.items(): + extraHeaders[key] = _toUtf8(value) + if key == "Destination": # copy/move header + if self.isConnectedToCatacomb: + extraHeaders[key] = _toUtf8(value.replace(Constants.SHARP, Constants.QUOTED_SHARP)) + + else: # in case of TAMINO 4.4 + extraHeaders[key] = _urlEncode(value) + # pass message to httplib class + for retry in range(0, Connection.MaxRetries): # retry loop + try: + self.logger.debug("REQUEST Send %s for %s" % (method, url)) + self.logger.debug("REQUEST Body: " + repr(body)) + for hdr in extraHeaders.items(): + self.logger.debug("REQUEST Header: " + repr(hdr)) + self.request(method, url, body, extraHeaders) + response = self.getresponse() + break # no retry needed + except (CannotSendRequest, socket.error, BadStatusLine, ResponseNotReady), exc: + # Workaround, start: reconnect and retry... + self.logger.debug("Exception: " + str(exc) + " Retry ... ") + self.close() + try: + self.connect() + except (CannotSendRequest, socket.error, BadStatusLine, ResponseNotReady), exc: + raise WebdavError("Cannot perform request. Connection failed.") + if retry == Connection.MaxRetries - 1: + raise WebdavError("Cannot perform request.") + return self.__evaluateResponse(method, response) + finally: + self._lock.release() + + def __evaluateResponse(self, method, response): + """ Evaluates the response of the WebDAV server. """ + + status, reason = response.status, response.reason + self.logger.debug("Method: " + method + " Status %d: " % status + reason) + + if status >= Constants.CODE_LOWEST_ERROR: # error has occured ? + self.logger.debug("ERROR Response: " + response.read()) + + # identify authentication CODE_UNAUTHORIZED, throw appropriate exception + if status == Constants.CODE_UNAUTHORIZED: + raise AuthorizationError(reason, status, response.msg["www-authenticate"]) + + response.close() + raise WebdavError(reason, status) + + if status == Constants.CODE_MULTISTATUS: + content = response.read() + ## check for UTF-8 encoding + try: + response.root = Parser().parse(content) + except ExpatError, error: + errorMessage = "Invalid XML document has been returned.\nReason: '%s'" % str(error.args) + raise WebdavError(errorMessage) + try: + response.msr = MultiStatusResponse(response.root) + except ResponseFormatError: + raise WebdavError("Invalid WebDAV response.") + response.close() + self.logger.debug("RESPONSE (Multi-Status): " + unicode(response.msr)) + elif method == 'LOCK' and status == Constants.CODE_SUCCEEDED: + response.parse_lock_response() + response.close() + elif method != 'GET' and method != 'PUT': + self.logger.debug("RESPONSE Body: " + response.read()) + response.close() + return response + + def addBasicAuthorization(self, user, password, realm=None): + if user and len(user) > 0: + self.__authorizationInfo = _BasicAuthenticationInfo(realm=realm, user=user, password=password) + + def addDigestAuthorization(self, user, password, realm, qop, nonce, uri = None, method = None): + if user and len(user) > 0: + # username, realm, password, uri, method, qop are required + self.__authorizationInfo = _DigestAuthenticationInfo(realm=realm, user=user, password=password, uri=uri, method=method, qop=qop, nonce=nonce) + + def putFile(self, path, srcfile, header={}): + self._lock.acquire() + try: + # Assemble header + try: + size = os.path.getsize(srcfile.name) + except os.error, error: + raise WebdavError("Cannot determine file size.\nReason: ''" % str(error.args)) + header["Content-length"] = str(size) + + contentType, contentEnc = mimetypes.guess_type(path) + if contentType: + header['Content-Type'] = contentType + if contentEnc: + header['Content-Encoding'] = contentEnc + if self.__authorizationInfo: + # update (digest) authorization data + if hasattr(self.__authorizationInfo, "update"): + self.__authorizationInfo.update(method="PUT", uri=path) + header["AUTHORIZATION"] = self.__authorizationInfo.authorization + + # send first request + path = _urlEncode(path) + try: + HTTPConnection.request(self, 'PUT', path, "", header) + filesize = os.path.getsize(srcfile.name) + self._blockCopySocket(srcfile, self, + Connection.blockSize,filesize) + srcfile.close() + response = self.getresponse() + except (CannotSendRequest, socket.error, BadStatusLine, ResponseNotReady), exc: + self.logger.debug("Exception: " + str(exc) + " Retry ... ") + raise WebdavError("Cannot perform request.") + status, reason = (response.status, response.reason) + self.logger.debug("Status %d: %s" % (status, reason)) + try: + if status >= Constants.CODE_LOWEST_ERROR: # error has occured ? + raise WebdavError(reason, status) + finally: + self.logger.debug("RESPONSE Body: " + response.read()) + response.close() + return response + finally: + self._lock.release() + + def _blockCopySocket(self, source, toSocket, blockSize, filesize): + transferedBytes = 0 + block = source.read(blockSize) + while len(block): + self.logger.debug("Wrote %d bytes." % len(block)) + transferedBytes += len(block) + toSocket.send(block) + from jarabe.journal.journalactivity import get_journal + get_journal().update_progress(transferedBytes/(filesize*1.0)) + block = source.read(blockSize) + self.logger.info("Transfered %d bytes." % transferedBytes) + + def __str__(self): + return self.protocol + "://" + self.host + ':' + str(self.port) + + +class _BasicAuthenticationInfo(object): + def __init__(self, **kwArgs): + self.__dict__.update(kwArgs) + self.cookie = base64.encodestring("%s:%s" % (self.user, self.password) ).strip() + self.authorization = "Basic " + self.cookie + self.password = None # protect password security + +class _DigestAuthenticationInfo(object): + + __nc = "0000000" # in hexadecimal without leading 0x + + def __init__(self, **kwArgs): + + self.__dict__.update(kwArgs) + + if self.qop is None: + raise WebdavError("Digest without qop is not implemented.") + if self.qop == "auth-int": + raise WebdavError("Digest with qop-int is not implemented.") + + def update(self, **kwArgs): + """ Update input data between requests""" + + self.__dict__.update(kwArgs) + + def _makeDigest(self): + """ Creates the digest information. """ + + # increment nonce count + self._incrementNc() + + # username, realm, password, uri, method, qop are required + + a1 = "%s:%s:%s" % (self.user, self.realm, self.password) + ha1 = hashlib.md5(a1).hexdigest() + + #qop == auth + a2 = "%s:%s" % (self.method, self.uri) + ha2 = hashlib.md5(a2).hexdigest() + + cnonce = str(uuid4()) + + responseData = "%s:%s:%s:%s:%s:%s" % (ha1, self.nonce, _DigestAuthenticationInfo.__nc, cnonce, self.qop, ha2) + digestResponse = hashlib.md5(responseData).hexdigest() + + authorization = "Digest username=\"%s\", realm=\"%s\", nonce=\"%s\", uri=\"%s\", algorithm=MD5, response=\"%s\", qop=auth, nc=%s, cnonce=\"%s\"" \ + % (self.user, self.realm, self.nonce, self.uri, digestResponse, _DigestAuthenticationInfo.__nc, cnonce) + return authorization + + authorization = property(_makeDigest) + + def _incrementNc(self): + _DigestAuthenticationInfo.__nc = self._dec2nc(self._nc2dec() + 1) + + def _nc2dec(self): + return int(_DigestAuthenticationInfo.__nc, 16) + + def _dec2nc(self, decimal): + return hex(decimal)[2:].zfill(8) + + +class WebdavError(IOError): + def __init__(self, reason, code=0): + IOError.__init__(self, code) + self.code = code + self.reason = reason + def __str__(self): + return self.reason + + +class AuthorizationError(WebdavError): + def __init__(self, reason, code, authHeader): + WebdavError.__init__(self, reason, code) + + self.authType = authHeader.split(" ")[0] + self.authInfo = authHeader + + +def _toUtf8(body): + if not body is None: + if type(body) == types.UnicodeType: + body = body.encode('utf-8') + return body + + +def _urlEncode(url): + if type(url) == types.UnicodeType: + url = url.encode('utf-8') + return urllib.quote(url) diff --git a/src/webdav/Constants.py b/src/webdav/Constants.py new file mode 100644 index 0000000..56dfd77 --- /dev/null +++ b/src/webdav/Constants.py @@ -0,0 +1,199 @@ +# pylint: disable-msg=C0103 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Contains XML tag names for the WebDAV protocol (RFC 2815) +and further WebDAV related constants. +""" + + +__version__ = "$Revision$"[11:-2] + + +QUOTED_SHARP = "%23" +SHARP = "#" + +# Date formats +DATE_FORMAT_ISO8601 = r"%Y-%m-%dT%H:%M:%SZ" +DATE_FORMAT_HTTP = r"%a, %d %b %Y %H:%M:%S GMT" # not used, substituted by rfc822 function + +NS_DAV = 'DAV:' +NS_TAMINO = 'http://namespaces.softwareag.com/tamino/response2' + +TAG_PROPERTY_FIND = 'propfind' +TAG_PROPERTY_NAME = 'propname' +TAG_PROPERTY_UPDATE = 'propertyupdate' +TAG_PROPERTY_SET = 'set' +TAG_PROPERTY_REMOVE = 'remove' +TAG_ALL_PROPERTY = 'allprop' +TAG_PROP = 'prop' + +TAG_MULTISTATUS = 'multistatus' +TAG_RESPONSE = 'response' +TAG_HREF = 'href' +TAG_PROPERTY_STATUS = 'propstat' +TAG_STATUS = 'status' +TAG_RESPONSEDESCRIPTION = 'responsdescription' + +PROP_CREATION_DATE = 'creationdate' +PROP_DISPLAY_NAME = 'displayname' +PROP_CONTENT_LANGUAGE = 'getcontentlanguage' +PROP_CONTENT_LENGTH = 'getcontentlength' +PROP_CONTENT_TYPE = 'getcontenttype' +PROP_ETAG = 'getetag' +PROP_MODIFICATION_DATE = 'modificationdate' # this property is supported by +# Tamino 4.4 but not by Catacomb; the date format is ISO8601 +PROP_LAST_MODIFIED = 'getlastmodified' +PROP_LOCK_DISCOVERY = 'lockdiscovery' +PROP_RESOURCE_TYPE = 'resourcetype' +PROP_SOURCE = 'source' +PROP_SUPPORTED_LOCK = 'supportedlock' +PROP_OWNER = 'owner' + +PROP_RESOURCE_TYPE_RESOURCE = 'resource' +PROP_RESOURCE_TYPE_COLLECTION = 'collection' + +TAG_LINK = 'link' +TAG_LINK_SOURCE = 'src' +TAG_LINK_DESTINATION = 'dst' + +TAG_LOCK_ENTRY = 'lockentry' +TAG_LOCK_SCOPE = 'lockscope' +TAG_LOCK_TYPE = 'locktype' +TAG_LOCK_INFO = 'lockinfo' +TAG_ACTIVE_LOCK = 'activelock' +TAG_LOCK_DEPTH = 'depth' +TAG_LOCK_TOKEN = 'locktoken' +TAG_LOCK_TIMEOUT = 'timeout' +TAG_LOCK_EXCLUSIVE = 'exclusive' +TAG_LOCK_SHARED = 'shared' +TAG_LOCK_OWNER = 'owner' + +# HTTP error code constants +CODE_MULTISTATUS = 207 +CODE_SUCCEEDED = 200 +CODE_CREATED = 201 +CODE_NOCONTENT = 204 + +CODE_LOWEST_ERROR = 300 + +CODE_UNAUTHORIZED = 401 +CODE_FORBIDDEN = 403 +CODE_NOT_FOUND = 404 +CODE_CONFLICT = 409 +CODE_PRECONDITION_FAILED = 412 +CODE_LOCKED = 423 # no permission +CODE_FAILED_DEPENDENCY = 424 + +CODE_OUTOFMEM = 507 + +# ? +CONFIG_UNICODE_URL = 1 + +# constants for WebDAV DASL according to draft + +TAG_SEARCH_REQUEST = 'searchrequest' +TAG_SEARCH_BASIC = 'basicsearch' +TAG_SEARCH_SELECT = 'select' +TAG_SEARCH_FROM = 'from' +TAG_SEARCH_SCOPE = 'scope' +TAG_SEARCH_WHERE = 'where' + +# constants for WebDAV ACP (according to draft-ietf-webdav-acl-09) below ... + +TAG_ACL = 'acl' +TAG_ACE = 'ace' +TAG_GRANT = 'grant' +TAG_DENY = 'deny' +TAG_PRIVILEGE = 'privilege' +TAG_PRINCIPAL = 'principal' +TAG_ALL = 'all' +TAG_AUTHENTICATED = 'authenticated' +TAG_UNAUTHENTICATED = 'unauthenticated' +TAG_OWNER = 'owner' +TAG_PROPERTY = 'property' +TAG_SELF = 'self' +TAG_INHERITED = 'inherited' +TAG_PROTECTED = 'protected' +TAG_SUPPORTED_PRIVILEGE = 'supported-privilege' +TAG_DESCRIPTION = 'description' + +# privileges for WebDAV ACP: +TAG_READ = 'read' +TAG_WRITE = 'write' +TAG_WRITE_PROPERTIES = 'write-properties' +TAG_WRITE_CONTENT = 'write-content' +TAG_UNLOCK = 'unlock' +TAG_READ_ACL = 'read-acl' +TAG_READ_CURRENT_USER_PRIVILEGE_SET = 'read-current-user-privilege-set' +TAG_WRITE_ACL = 'write-acl' +TAG_ALL = 'all' +TAG_BIND = 'bind' +TAG_UNBIND = 'unbind' +# Tamino-specific privileges +TAG_TAMINO_SECURITY = 'security' +# Limestone-specific privileges +TAG_BIND_COLLECTION = 'bind-collection' +TAG_UNBIND_COLLECTION = 'unbind-collection' +TAG_READ_PRIVATE_PROPERTIES = 'read-private-properties' +TAG_WRITE_PRIVATE_PROPERTIES = 'write-private-properties' + +# properties for WebDAV ACP: +PROP_CURRENT_USER_PRIVILEGE_SET = 'current-user-privilege-set' +PROP_SUPPORTED_PRIVILEGE_SET = 'supported-privilege-set' +PROP_PRINCIPAL_COLLECTION_SET = 'principal-collection-set' + +# reports for WebDAV ACP +REPORT_ACL_PRINCIPAL_PROP_SET = 'acl-principal-prop-set' + + + +# constants for WebDAV Delta-V + +# WebDAV Delta-V method names +METHOD_REPORT = 'REPORT' +METHOD_VERSION_CONTROL = 'VERSION-CONTROL' +METHOD_UNCHECKOUT = 'UNCHECKOUT' +METHOD_CHECKOUT = 'CHECKOUT' +METHOD_CHECKIN = 'CHECKIN' +METHOD_UPDATE = 'UPDATE' + +# Special properties +PROP_SUCCESSOR_SET = (NS_DAV, 'successor-set') +PROP_PREDECESSOR_SET = (NS_DAV, 'predecessor-set') +PROP_VERSION_HISTORY = (NS_DAV, 'version-history') +PROP_CREATOR = (NS_DAV, 'creator-displayname') +PROP_VERSION_NAME = (NS_DAV, 'version-name') +PROP_CHECKEDIN = (NS_DAV, 'checked-in') +PROP_CHECKEDOUT = (NS_DAV, 'checked-out') +PROP_COMMENT = (NS_DAV, 'comment') + +# XML tags for request body +TAG_VERSION_TREE = 'version-tree' +TAG_LOCATE_BY_HISTORY = 'locate-by-history' +TAG_UPDATE = 'update' +TAG_VERSION = 'version' + +# HTTP header constants +HTTP_HEADER_DEPTH_INFINITY = 'infinity' +HTTP_HEADER_IF = 'if' +HTTP_HEADER_DAV = 'dav' +HTTP_HEADER_DASL = 'dasl' +HTTP_HEADER_OPTION_ACL = 'access-control' +HTTP_HEADER_OPTION_DAV_BASIC_SEARCH = 'DAV:basicsearch' +HTTP_HEADER_SERVER = 'server' +HTTP_HEADER_SERVER_TAMINO = 'Apache/2.0.54 (Win32)' diff --git a/src/webdav/Makefile.am b/src/webdav/Makefile.am new file mode 100644 index 0000000..3356daf --- /dev/null +++ b/src/webdav/Makefile.am @@ -0,0 +1,20 @@ +SUBDIRS = acp + +sugardir = $(pythondir)/webdav +sugar_PYTHON = \ + Connection.py \ + davlib.py \ + logger.py \ + NameCheck.py \ + Utils.py \ + VersionHandler.py \ + WebdavRequests.py \ + Condition.py \ + Constants.py \ + __init__.py \ + qp_xml.py \ + uuid_.py \ + WebdavClient.py \ + WebdavResponse.py + + diff --git a/src/webdav/NameCheck.py b/src/webdav/NameCheck.py new file mode 100644 index 0000000..7976973 --- /dev/null +++ b/src/webdav/NameCheck.py @@ -0,0 +1,193 @@ +# pylint: disable-msg=R0904,W0142,W0511,W0104,C0321,E1103,W0212 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Check name of new collections/resources for "illegal" characters. +""" + + +import re +import unicodedata + + +__version__ = "$LastChangedRevision$" + + +_unicodeUmlaut = [unicodedata.lookup("LATIN CAPITAL LETTER A WITH DIAERESIS"), + unicodedata.lookup("LATIN SMALL LETTER A WITH DIAERESIS"), + unicodedata.lookup("LATIN CAPITAL LETTER O WITH DIAERESIS"), + unicodedata.lookup("LATIN SMALL LETTER O WITH DIAERESIS"), + unicodedata.lookup("LATIN CAPITAL LETTER U WITH DIAERESIS"), + unicodedata.lookup("LATIN SMALL LETTER U WITH DIAERESIS"), + unicodedata.lookup("LATIN SMALL LETTER SHARP S")] + +# Define characters and character base sets +_german = u"".join(_unicodeUmlaut) +_alpha = "A-Za-z" +_num = "0-9" +_alphaNum = _alpha + _num +_space = " " +_under = "_" +_dash = "\-" +_dot = "\." +_exclam = "\!" +_tilde = "\~" +_dollar = "\$" +_plus = "+" +_equal = "=" +_sharp = "#" + +# Define character groups +_letterNum = _alphaNum + _german +_letter = _alpha + _german + +# Define character sets for names +firstPropertyChar = _letter + _under +propertyChar = firstPropertyChar + _num + _dash + _dot +firstResourceChar = firstPropertyChar + _num + _tilde + _exclam + _dollar + \ + _dot + _dash + _plus + _equal + _sharp +resourceChar = firstResourceChar + _space + +# Define regular expressions for name validation +_propertyFirstRe = re.compile(u"^["+ firstPropertyChar +"]") + +_propertyRe = re.compile(u"[^"+ propertyChar +"]") +_resourceFirstRe = re.compile(u"^["+ firstResourceChar +"]") +_resourceRe = re.compile(u"[^"+ resourceChar +"]") + + +def isValidPropertyName(name): + """ + Check if the given property name is valid. + + @param name: Property name. + @type name: C{unicode} + + @return: Boolean indicating whether the given property name is valid or not. + @rtype: C{bool} + """ + + illegalChar = _propertyRe.search(name) + return illegalChar == None and _propertyFirstRe.match(name) != None + + +def isValidResourceName(name): + """ + Check if the given resource name is valid. + + @param name: Resource name. + @type name: C{unicode} + + @return: Boolean indicating whether the given resource name is valid or not. + @rtype: C{bool} + """ + + illegalChar = _resourceRe.search(name) + return illegalChar == None and _resourceFirstRe.match(name) != None + + +def validatePropertyName(name): + """ + Check if the given property name is valid. + + @param name: Property name. + @type name: C{unicode} + @raise WrongNameError: if validation fails (see L{datafinder.common.NameCheck.WrongNameError}) + """ + + illegalChar = _propertyRe.search(name) + if illegalChar: + raise WrongNameError(illegalChar.start(), name[illegalChar.start()]) + if not _propertyFirstRe.match(name): + if len(name) > 0: + raise WrongNameError(0, name[0]) + else: + raise WrongNameError(0, 0) + + +def validateResourceName(name): + """ + Check if the given resource name is valid. + + @param name: name of resource/collection + @type name: C{unicode} + @raise WrongNameError: if validation fails (@see L{datafinder.common.NameCheck.WrongNameError}) + """ + + illegalChar = _resourceRe.search(name) + if illegalChar: + raise WrongNameError(illegalChar.start(), name[illegalChar.start()]) + if not _resourceFirstRe.match(name): + if len(name) > 0: + raise WrongNameError(0, name[0]) + else: + raise WrongNameError(0, 0) + + +def getResourceNameErrorPosition(name): + """ + Get position of illegal character (and the error-message). + This method can be used to get this information if L{isValidPropertyName} + or L{isValidResourceName} failed. + + @param name: Resource name. + @type name: C{unicode} + + @return: Tuple of error position and message. + @rtype: C{tuple} of C{int} and C{unicode} + """ + + result = (-1, None) + illegalChar = _resourceRe.search(name) + if illegalChar: + result = (illegalChar.start(), \ + u"Illegal character '%s' at index %d." % \ + (name[illegalChar.start()], illegalChar.start())) + elif not _resourceFirstRe.match(name): + result = (0, u"Illegal character '%s' at index %d." % (name[0], 0)) + return result + + +class WrongNameError(ValueError): + """ + Exception raised if an "illegal" character was found. + + @ivar character: character that caused the exception + @type character: C{unicode} + @ivar position: position of C{character} + @type position: C{int} + """ + + def __init__(self, position, character): + """ + Constructor. + + @param character: Character that caused the exception. + @type character: C{unicode} + @param position: Position of C{character} + @type position: C{int} + """ + + ValueError.__init__(self) + self.character = character + self.position = position + + def __str__(self): + """ Returns string representation. """ + + return ValueError.__str__(self) + \ + "Character '%s' at index %d." % (self.character, self.position) diff --git a/src/webdav/Utils.py b/src/webdav/Utils.py new file mode 100644 index 0000000..ec05755 --- /dev/null +++ b/src/webdav/Utils.py @@ -0,0 +1,154 @@ +# pylint: disable-msg=W0141,R0912 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +The module contains functions to support use of the WebDav functionalities. +""" + + +import os +import sys + +from webdav.WebdavClient import CollectionStorer, ResourceStorer +from webdav.Constants import NS_DAV, PROP_RESOURCE_TYPE, CODE_NOT_FOUND, PROP_RESOURCE_TYPE_RESOURCE +from webdav.Connection import WebdavError + + +__version__ = "$Revision$"[11:-2] + + +def resourceExists(node, name = None, resourceType = PROP_RESOURCE_TYPE_RESOURCE): + """ + Check if resource exists. + + Usage: + - resourceExists(ResourceStorer-object): + check if resource exists + - resourceExists(CollectionStorer-object, name): + check if resource name exists in collection + + @param node: node that has to be checked or node of collection + @type node: L{ResourceStorer} + @param name: name of resource (in collection node) that has to be checked + @type name: string + + @return: boolean + + @raise WebdavError: all WebDAV errors except WebDAV error 404 (not found) + """ + + exists = False + if not node: + return exists + try: + myResourceType = "" + if name: + # make sure it's unicode: + if not isinstance(name, unicode): + name = name.decode(sys.getfilesystemencoding()) + url = node.url + if url.endswith("/"): + url = url + name + else: + url = url + "/" + name + newNode = ResourceStorer(url, node.connection) + element = newNode.readProperty(NS_DAV, PROP_RESOURCE_TYPE) + else: # name is "None": + element = node.readProperty(NS_DAV, PROP_RESOURCE_TYPE) + + if len(element.children) > 0: + myResourceType = element.children[0].name + if resourceType == myResourceType or resourceType == PROP_RESOURCE_TYPE_RESOURCE: + exists = True + else: + exists = False + except WebdavError, wderr: + if wderr.code == CODE_NOT_FOUND: + # node doesn't exist -> exists = False: + exists = False + else: + # another exception occured -> "re-raise" it: + raise + return exists + + +def downloadCollectionContent(destinationPath, collectionToDownload): + """ + Downloads the resources contained to the given directory. + + @param destinationPath: Path to download the files to, will be created if it not exists. + @type destinationPath: C{String} + @param collectionToDownload: Collection to download the content from. + @type collectionToDownload: instance of L{CollectionStorer} + + @raise WebdavError: If something goes wrong. + """ + + from time import mktime, gmtime + + downloadCount = 0 + + listOfItems = collectionToDownload.getCollectionContents() + + if not os.path.exists(destinationPath): + try: + os.makedirs(destinationPath) + except OSError: + errorMessage = "Cannot create download destination directory '%s'." % destinationPath + raise WebdavError(errorMessage) + + try: + itemsInPath = os.listdir(destinationPath) + except OSError: + errorMessage = "Cannot read the content of download destination directory '%s'." % destinationPath + raise WebdavError(errorMessage) + + for item in listOfItems: + # skip collections + if not isinstance(item[0], CollectionStorer): + itemSavePath = os.path.join(destinationPath, item[0].name) + existsItemSavePath = os.path.exists(itemSavePath) + + # update? + if existsItemSavePath: + try: + isUpdateNecessary = mktime(item[1].getLastModified()) > mktime(gmtime(os.path.getmtime(itemSavePath))) + except (ValueError, OverflowError): + isUpdateNecessary = True + # windows is not case sensitive + for realItem in itemsInPath: + if realItem.lower() == item[0].name.lower(): + itemsInPath.remove(realItem) + else: + isUpdateNecessary = True + + # download + if not existsItemSavePath or (existsItemSavePath and isUpdateNecessary): + item[0].downloadFile(itemSavePath) + downloadCount = downloadCount + 1 + + # delete old items + try: + for item in itemsInPath: + os.remove(os.path.join(destinationPath, item)) + except OSError, e: + if e.errno == 13: # permission error + sys.stderr.write("permission problem on '%s' in %s\n" % (e.filename, e.strerror)) + else: + raise + + return downloadCount diff --git a/src/webdav/VersionHandler.py b/src/webdav/VersionHandler.py new file mode 100644 index 0000000..a1962c6 --- /dev/null +++ b/src/webdav/VersionHandler.py @@ -0,0 +1,198 @@ +# pylint: disable-msg=W0612,W0142 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +The WebDAV client module forwards Delta-V related method invocations to +the following VersionHandler class. +""" + +__version__ = '$Revision$'[11:-2] + + +import types + +from webdav import Constants +from davlib import XML_CONTENT_TYPE, XML_DOC_HEADER + + +class VersionHandler(object): + """ + Implements a client interface for WebDAV Delta-V methods + For the Delta-V see RFC 3253 at http://www.ietf.org/rfc/rfc3253.txt + """ + + # restrict instance variables + __slots__ = ('path', 'connection') + + + def __init__(self, connection, path): + """ + Construct a VersionHandler with a URL path and a WebDAV connection. + This constructor must not be called outside class ResourceStorer. + + @param connection: L{webdav.Connection} instance + @param path: resource's path part of URL + """ + #assert isinstance(connection, Connection), \ + # "Class of connection is %s." % connection.__class__.__name__ + self.connection = connection + self.path = path + + + def activateVersionControl(self): + """ + Turns version control on for this resource. + The resource becomes a version controlled resource (VCR) + """ + response = self.connection._request(Constants.METHOD_VERSION_CONTROL, self.path, None, {}) + # set auto-versioning to DAV:locked-checkout + ## parse response body in case of an error + + def uncheckout(self, lockToken=None): + """ + Undos a previous check-out operation on this VCR. + The VCR is reverted to the state before the checkout/lock operation. + Beware: Property or content changes will be lost ! + A (optional) lock has to be removed seperatedly. + + @param lockToken: returned by a preceeding lock() method invocation or None + """ + headers = {} + if lockToken: + headers = lockToken.toHeader() + response = self.connection._request(Constants.METHOD_UNCHECKOUT, self.path, None, headers) + ## parse response body in case of an error + + def listAllVersions(self): + """ + List version history. + + @return: List of versions for this VCR. Each version entry is a tuple adhering + to the format (URL-path, name, creator, tuple of successor URL-paths). + If there are no branches then there is at most one successor within the tuple. + """ + # implementation is similar to the propfind method + headers = {} + headers['Content-Type'] = XML_CONTENT_TYPE + body = _createReportVersionTreeBody() + response = self.connection._request(Constants.METHOD_REPORT, self.path, body, headers) + # response is multi-status + result = [] + for path, properties in response.msr.items(): + # parse the successor-set value from XML into alist + result.append( (path, str(properties[Constants.PROP_VERSION_NAME]), \ + str(properties[Constants.PROP_CREATOR]), \ + _extractSuccessorList(properties[Constants.PROP_SUCCESSOR_SET])) ) + ## TODO: sort for path and produce list + result.sort() + return result + + # warning: not tested yet + def readVersionProperties(self): + """ + Provide version related information on this VCR. + This include a reference to the latest version resource, + check-out state information and a comment. + + @return: map of version properties with values. + """ + versionProperties = (Constants.PROP_CHECKEDIN, Constants.PROP_CHECKEDOUT, Constants.PROP_COMMENT) + return self.connection.readProperties(*versionProperties) + + + def revertToVersion(self, oldVersion): + """ + Revert this VCR to the given version. + Beware: All versions succeeding the given version are made unavailable. + + @param oldVersion: URL-path of a previous version of this VCR. + """ + ## send an update request + assert isinstance(oldVersion, types.StringType) or isinstance(oldVersion, types.UnicodeType) + response = self.connection._request(Constants.METHOD_UPDATE, self.path, + _createUpdateBody(oldVersion), {}) + return response + + + # the following is not needed when using auto-versioning + + # warning: not tested yet + def checkout(self): + """ + Marks resource as checked-out + This is usually followed by a GET (download) operation. + """ + response = self.connection._request(Constants.METHOD_CHECKOUT, self.path, None, {}) + ## parse response body in case of an error + + # warning: not tested yet + def checkin(self): + """ + Creates a new version from the VCR's content. + This opeartion is usually preceeded by one or more write operations. + """ + response = self.connection._request(Constants.METHOD_CHECKIN, self.path, None, {}) + versionUrl = response.getheader('Location') + return versionUrl + ## parse response body in case of an error + + + + +# Helper functions +def _createReportVersionTreeBody(): + """ + TBD + + @return: ... + @rtype: string + """ + versions = 'D:' + Constants.TAG_VERSION_TREE + prop = 'D:' + Constants.TAG_PROP + nameList = [Constants.PROP_SUCCESSOR_SET, Constants.PROP_VERSION_NAME, Constants.PROP_CREATOR] + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s>' % (versions, prop) + \ + reduce(lambda xml, name: xml + "" % name[1], [''] + nameList) + \ + '' % (prop, versions) + +def _createUpdateBody(path): + """ + TBD + + @return: ... + @rtype: string + """ + update = 'D:' + Constants.TAG_UPDATE + version = 'D:' + Constants.TAG_VERSION + href = 'D:' + Constants.TAG_HREF + #PROP = 'D:' + TAG_PROP + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s><%s>' % (update, version, href) + \ + path + \ + '' % (href, version, update) + +def _extractSuccessorList(element): + """ + TBD + + @return: ... + @rtype: tuple of strings + """ + result = [] + for href in element.children: + result.append(href.textof()) + return tuple(result) diff --git a/src/webdav/WebdavClient.py b/src/webdav/WebdavClient.py new file mode 100644 index 0000000..8ce5c77 --- /dev/null +++ b/src/webdav/WebdavClient.py @@ -0,0 +1,848 @@ +# pylint: disable-msg=R0904,W0142,W0511,W0104,C0321,E1103,W0212 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +This module contains the classes ResourceStorer and CollectionStorer for accessing WebDAV resources. +""" + + +from davlib import XML_CONTENT_TYPE + +from urlparse import urlsplit +import re +import types +import sys +import os +import shutil +import socket + +from webdav import Constants +from webdav.WebdavResponse import LiveProperties +from webdav.WebdavRequests import createFindBody, createUpdateBody, createDeleteBody, createSearchBody +from webdav.Condition import ConditionTerm +from webdav.Connection import Connection, WebdavError, AuthorizationError +from webdav.VersionHandler import VersionHandler + +from webdav.acp.Privilege import Privilege +from webdav.acp.Acl import ACL +from webdav.NameCheck import validateResourceName, WrongNameError + + +__version__ = '$Revision$'[11:-2] + +SOCKET_DEFAULT_TIMEOUT = 10 + + +def switchUnicodeUrlOn(switch): + """ + Configure whether to use unicode (UTF-8) encoded URLs (default) or + Latin-1 encoded URLs. + + @param switch: 1 if unicode URLs shall be used + """ + + assert switch == 0 or switch == 1, "Pass boolean argument, please." + Constants.CONFIG_UNICODE_URL = switch + + +def parseDigestAuthInfo(authInfo): + """ + Parses the authentication information returned from a server and returns + a dictionary containing realm, qop, and nonce. + + @see: L{AuthorizationError} + or the main function of this module. + """ + + info = dict() + info["realm"] = re.search('realm="([^"]+)"', authInfo).group(1) + info["qop"] = re.search('qop="([^"]+)"', authInfo).group(1) + info["nonce"] = re.search('nonce="([^"]+)"', authInfo).group(1) + return info + + +class ResourceStorer(object): + """ + This class provides client access to a WebDAV resource + identified by an URI. It provides all WebDAV class 2 features which include + uploading data, getting and setting properties qualified by a XML name space, + locking and unlocking the resource. + This class does not cache resource data. This has to be performed by its clients. + + @author: Roland Betz + """ + + # Instance properties + url = property(lambda self: str(self.connection) + self.path, None, None, "Resource's URL") + + def __init__(self, url, connection=None, validateResourceNames=True): + """ + Creates an instance for the given URL + User must invoke validate() after construction to check the resource on the server. + + @param url: Unique resource location for this storer. + @type url: C{string} + @param connection: this optional parameter contains a Connection object + for the host part of the given URL. Passing a connection saves + memory by sharing this connection. (defaults to None) + @type connection: L{webdav.Connection} + @raise WebdavError: If validation of resource name path parts fails. + """ + + assert connection == None or isinstance(connection, Connection) + parts = urlsplit(url, allow_fragments=False) + self.path = parts[2] + self.validateResourceNames = validateResourceNames + + # validate URL path + for part in self.path.split('/'): + if part != '' and not "ino:" in part: # explicitly allowing this character sequence as a part of a path (Tamino 4.4) + if self.validateResourceNames: + try: + validateResourceName(part) + except WrongNameError: + raise WebdavError("Found invalid resource name part.") + self.name = part + # was: filter(lambda part: part and validateResourceName(part), self.path.split('/')) + # but filter is deprecated + + self.defaultNamespace = None # default XML name space of properties + if connection: + self.connection = connection + else: + conn = parts[1].split(":") + if len(conn) == 1: + self.connection = Connection(conn[0], protocol = parts[0]) # host and protocol + else: + self.connection = Connection(conn[0], int(conn[1]), protocol = parts[0]) # host and port and protocol + self.versionHandler = VersionHandler(self.connection, self.path) + + + def validate(self): + """ + Check whether URL contains a WebDAV resource + Uses the WebDAV OPTIONS method. + + @raise WebdavError: L{WebdavError} if URL does not contain a WebDAV resource + """ + #davHeader = response.getheader(HTTP_HEADER_DAV) + davHeader = self.getSpecificOption(Constants.HTTP_HEADER_DAV) + self.connection.logger.debug("HEADER DAV: %s" % davHeader) + if not(davHeader) or davHeader.find("2") < 0: # DAV class 2 supported ? + raise WebdavError("URL does not support WebDAV", 0) + + def options(self): + """ + Send an OPTIONS request to server and return all HTTP headers. + + @return: map of all HTTP headers returned by the OPTIONS method. + """ + response = self.connection.options(self.path) + result = {} + result.update(response.msg) + self.connection.logger.debug("OPTION returns: " + str(result.keys())) + return result + + def _getAclSupportAvailable(self): + """ + Returns True if the current connection has got ACL support. + + @return: ACL support (True / False) + @rtype: C{bool} + """ + options = self.getSpecificOption(Constants.HTTP_HEADER_DAV) + if options.find(Constants.HTTP_HEADER_OPTION_ACL) >= 0: + return True + else: + return False + + aclSupportAvailable = property(_getAclSupportAvailable) + + def _getDaslBasicsearchSupportAvailable(self): + """ + Returns True if the current connection supports DASL basic search. + + @return: DASL basic search support (True / False) + @rtype: C{bool} + """ + options = self.getSpecificOption(Constants.HTTP_HEADER_DASL) + if not options or \ + not options.find(Constants.HTTP_HEADER_OPTION_DAV_BASIC_SEARCH) >= 0: + return False + else: + return True + + daslBasicsearchSupportAvailable = property(_getDaslBasicsearchSupportAvailable) + + def isConnectedToCatacombServer(self): + """ + Returns True if connected to a Catacomb WebDav server. + + @return: if connected to Catacomb Webdav server (True / False) + @rtype: C{bool} + """ + if not self.connection.serverTypeChecked: + options = self.getSpecificOption(Constants.HTTP_HEADER_SERVER) + if options.find(Constants.HTTP_HEADER_SERVER_TAMINO) >= 0: + self.connection.isConnectedToCatacomb = False + else: + self.connection.isConnectedToCatacomb = True + self.connection.serverTypeChecked = True + return self.connection.isConnectedToCatacomb + + def getSpecificOption(self, option): + """ + Returns specified WebDav options. + @param option: name of the option + + @return: String containing the value of the option. + @rtype: C{string} + """ + options = '' + try: + options = self.options().get(option) + except KeyError: + return options + return options + + ### delegate some method invocations + def __getattr__(self, name): + """ + Build-in method: + Forwards unknow lookups (methods) to delegate object 'versionHandler'. + + @param name: name of unknown attribute + """ + # delegate Delta-V methods + return getattr(self.versionHandler, name) + + def copy(self, toUrl, infinity=True): + """ + Copies this resource. + + @param toUrl: target URI path + @param infinity: Flag that indicates that the complete content of collection is copied. (default) + @type depth: C{boolean} + """ + self.connection.logger.debug("Copy to " + repr(toUrl)); + _checkUrl(toUrl) + if infinity: + response = self.connection.copy(self.path, toUrl) + else: + response = self.connection.copy(self.path, toUrl, 0) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + def delete(self, lockToken=None): + """ + Deletes this resource. + + @param lockToken: String returned by last lock operation or null. + @type lockToken: L{LockToken} + """ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + response = self.connection.delete(self.path, header) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + def move(self, toUrl): + """ + Moves this resource to the given path or renames it. + + @param toUrl: new (URI) path + """ + self.connection.logger.debug("Move to " + repr(toUrl)); + _checkUrl(toUrl) + response = self.connection.move(self.path, toUrl) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + + def lock(self, owner): + """ + Locks this resource for exclusive write access. This means that for succeeding + write operations the returned lock token has to be passed. + If the methode does not throw an exception the lock has been granted. + + @param owner: describes the lock holder + @return: lock token string (automatically generated) + @rtype: L{LockToken} + """ + response = self.connection.lock(self.path, owner) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + return LockToken(self.url, response.locktoken) + + def unlock(self, lockToken): + """ + Removes the lock from this resource. + + @param lockToken: which has been return by the lock() methode + @type lockToken: L{LockToken} + """ + self.connection.unlock(self.path, lockToken.token) + + + def deleteContent(self, lockToken=None): + """ + Delete binary data at permanent storage. + + @param lockToken: None or lock token from last lock request + @type lockToken: L{LockToken} + """ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + self.connection.put(self.path, "", extra_hdrs=header) + + def uploadContent(self, content, lockToken=None): + """ + Write binary data to permanent storage. + + @param content: containing binary data + @param lockToken: None or lock token from last lock request + @type lockToken: L{LockToken} + """ + assert not content or isinstance(content, types.UnicodeType) or\ + isinstance(content, types.StringType), "Content is not a string: " + content.__class__.__name__ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + response = None + if not content is None: + header["Content-length"] = len(content) + else: + header["Content-length"] = 0 + + # We need to change the header["Content-length"] to a string. + header["Content-length"] = str(header["Content-length"]) + + try: + response = self.connection.put(self.path, content, extra_hdrs=header) + finally: + if response: + self.connection.logger.debug(response.read()) + response.close() + + def uploadFile(self, newFile, lockToken=None): + """ + Write binary data to permanent storage. + + @param newFile: File containing binary data. + @param lockToken: None or lock token from last lock request + @type lockToken: L{LockToken} + """ + assert isinstance(newFile, types.FileType), "Argument is no file: " + file.__class__.__name__ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + self.connection.putFile(self.path, newFile, header=header) + + def downloadContent(self): + """ + Read binary data from permanent storage. + """ + response = self.connection.get(self.path) + # TODO: Other interface ? return self.connection.getfile() + return response + + def downloadFile(self, localFileName, show_progress=False, + filesize=0): + """ + Copy binary data from permanent storage to a local file. + + @param localFileName: file to write binary data to + """ + localFile = open(localFileName, 'wb') + remoteFile = self.downloadContent() + try: + socket.setdefaulttimeout(SOCKET_DEFAULT_TIMEOUT) + _blockCopyFile(remoteFile, localFile, Connection.blockSize, + show_progress, filesize) + except socket.error, e: + raise e + remoteFile.close() + localFile.close() + + def readProperties(self, *names): + """ + Reads the given properties. + + @param names: a list of property names. + A property name is a (XmlNameSpace, propertyName) tuple. + @return: a map from property names to DOM Element or String values. + """ + assert names, "Property names are missing." + body = createFindBody(names, self.defaultNamespace) + response = self.connection.propfind(self.path, body, depth=0) + properties = response.msr.values()[0] + if properties.errorCount > 0: + raise WebdavError("Property is missing on '%s': %s" % (self.path, properties.reason), properties.code) + return properties + + def readProperty(self, nameSpace, name): + """ + Reads the given property. + + @param nameSpace: XML-namespace + @type nameSpace: string + @param name: A property name. + @type name: string + + @return: a map from property names to DOM Element or String values. + """ + results = self.readProperties((nameSpace, name)) + if len(results) == 0: + raise WebdavError("Property is missing: " + results.reason) + return results.values()[0] + + def readAllProperties(self): + """ + Reads all properties of this resource. + + @return: a map from property names to DOM Element or String values. + """ + response = self.connection.allprops(self.path, depth=0) + return response.msr.values()[0] + + def readAllPropertyNames(self): + """ + Returns the names of all properties attached to this resource. + + @return: List of property names + """ + response = self.connection.propnames(self.path, depth=0) + return response.msr.values()[0] + + def readStandardProperties(self): + """ + Read all WebDAV live properties. + + @return: A L{LiveProperties} instance which contains a getter method for each live property. + """ + body = createFindBody(LiveProperties.NAMES, Constants.NS_DAV) + response = self.connection.propfind(self.path, body, depth=0) + properties = response.msr.values()[0] + return LiveProperties(properties) + + def writeProperties(self, properties, lockToken=None): + """ + Sets or updates the given properties. + + @param lockToken: if the resource has been locked this is the lock token. + @type lockToken: L{LockToken} + @param properties: a map from property names to a String or + DOM element value for each property to add or update. + """ + assert isinstance(properties, types.DictType) + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + body = createUpdateBody(properties, self.defaultNamespace) + response = self.connection.proppatch(self.path, body, header) + if response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + def deleteProperties(self, lockToken=None, *names): + """ + Removes the given properties from this resource. + + @param lockToken: if the resource has been locked this is the lock token. + @type lockToken: L{LockToken} + @param names: a collection of property names. + A property name is a (XmlNameSpace, propertyName) tuple. + """ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + body = createDeleteBody(names, self.defaultNamespace) + response = self.connection.proppatch(self.path, body, header) + if response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + # ACP extension + def setAcl(self, acl, lockToken=None): + """ + Sets ACEs in the non-inherited and non-protected ACL or the resource. + This is the implementation of the ACL method of the WebDAV ACP. + + @param acl: ACL to be set on resource as ACL object. + @param lockToken: If the resource has been locked this is the lock token (defaults to None). + @type lockToken: L{LockToken} + """ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + headers = {} + if lockToken: + headers = lockToken.toHeader() + headers['Content-Type'] = XML_CONTENT_TYPE + body = acl.toXML() + response = self.connection._request('ACL', self.path, body, headers) + return response + ## TODO: parse DAV:error response + + def getAcl(self): + """ + Returns this resource's ACL in an ACL instance. + + @return: Access Control List. + @rtype: L{ACL} + """ + xmlAcl = self.readProperty(Constants.NS_DAV, Constants.TAG_ACL) + return ACL(xmlAcl) + + def getCurrentUserPrivileges(self): + """ + Returns a tuple of the current user privileges. + + @return: list of Privilege instances + @rtype: list of L{Privilege} + """ + privileges = self.readProperty(Constants.NS_DAV, Constants.PROP_CURRENT_USER_PRIVILEGE_SET) + result = [] + for child in privileges.children: + result.append(Privilege(domroot=child)) + return result + + def getPrincipalCollections(self): + """ + Returns a list principal collection URLs. + + @return: list of principal collection URLs + @rtype: C{list} of C{unicode} elements + """ + webdavQueryResult = self.readProperty(Constants.NS_DAV, Constants.PROP_PRINCIPAL_COLLECTION_SET) + principalCollectionList = [] + for child in webdavQueryResult.children: + principalCollectionList.append(child.first_cdata) + return principalCollectionList + + def getOwnerUrl(self): + """ Explicitly retireve the Url of the owner. """ + + result = self.readProperty(Constants.NS_DAV, Constants.PROP_OWNER) + if result and len(result.children): + return result.children[0].textof() + return None + +class CollectionStorer(ResourceStorer): + """ + This class provides client access to a WebDAV collection resource identified by an URI. + This class does not cache resource data. This has to be performed by its clients. + + @author: Roland Betz + """ + + def __init__(self, url, connection=None, validateResourceNames=True): + """ + Creates a CollectionStorer instance for a URL and an optional Connection object. + User must invoke validate() after constuction to check the resource on the server. + + @see: L{webdav.WebdavClient.ResourceStorer.__init__} + @param url: unique resource location for this storer + @param connection: this optional parameter contains a Connection object for the host part + of the given URL. Passing a connection saves memory by sharing this connection. + """ + if url[-1] != '/': # Collection URL must end with slash + url += '/' + ResourceStorer.__init__(self, url, connection, validateResourceNames) + + def getResourceStorer(self, name): + """ + Return a ResourceStorer instance for a child resource (member) of this Collection. + + @param name: leaf name of child resource + @return: L{ResourceStorer} instance + """ + assert isinstance(name, types.StringType) or isinstance(name, types.UnicodeType) + return ResourceStorer(self.url + name, self.connection, self.validateResourceNames) + + def validate(self): + """ + Check whether this URL contains a WebDAV collection. + Uses the WebDAV OPTION method. + + @raise WebdavError: L{WebdavError} if URL does not contain a WebDAV collection resource. + """ + super(CollectionStorer, self).validate() + isCollection = self.readProperty(Constants.NS_DAV, Constants.PROP_RESOURCE_TYPE) + if not (isCollection and isCollection.children): + raise WebdavError("Not a collection URL.", 0) + + def addCollection(self, name, lockToken=None): + """ + Make a new WebDAV collection resource within this collection. + + @param name: of the new collection + @param lockToken: None or token returned by last lock operation + @type lockToken: L{LockToken} + """ + assert isinstance(name, types.StringType) or isinstance(name, types.UnicodeType) + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + if self.validateResourceNames: + validateResourceName(name) + if name[-1] != '/': # Collection URL must end with slash + name += '/' + self.connection.mkcol(self.path + name, header) + return CollectionStorer(self.url + name, self.connection, self.validateResourceNames) + + def addResource(self, name, content=None, properties=None, lockToken=None): + """ + Create a new empty WebDAV resource contained in this collection with the given + properties. + + @param name: leaf name of the new resource + @param content: None or initial binary content of resource + @param properties: name/value-map containing properties + @param lockToken: None or token returned by last lock operation + @type lockToken: L{LockToken} + """ + assert isinstance(name, types.StringType) or isinstance(name, types.UnicodeType) + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + if self.validateResourceNames: + validateResourceName(name) # check for invalid characters + resource_ = ResourceStorer(self.url + name, self.connection, self.validateResourceNames) + resource_.uploadContent(content, lockToken) + if properties: + resource_.writeProperties(properties, lockToken) + return resource_ + + def deleteResource(self, name, lockToken=None): + """ + Delete a collection which is contained within this collection + + @param name: leaf name of a contained collection resource + @param lockToken: None or token returned by last lock operation + @type lockToken: L{LockToken} + """ + assert isinstance(name, types.StringType) or isinstance(name, types.UnicodeType) + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + if self.validateResourceNames: + validateResourceName(name) + response = self.connection.delete(self.path + name, header) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: %s" % response.msr.reason, response.msr.code) + + def lockAll(self, owner): + """ + Locks this collection resource for exclusive write access. This means that for + succeeding write operations the returned lock token has to be passed. + The operation is applied recursively to all contained resources. + If the methode does not throw an exception then the lock has been granted. + + @param owner: describes the lock holder + @return: Lock token string (automatically generated). + @rtype: L{LockToken} + """ + assert isinstance(owner, types.StringType) or isinstance(owner, types.UnicodeType) + response = self.connection.lock(self.path, owner, depth=Constants.HTTP_HEADER_DEPTH_INFINITY) + return LockToken(self.url, response.locktoken) + + def listResources(self): + """ + Describe all members within this collection. + + @return: map from URI to a L{LiveProperties} instance containing the WebDAV + live attributes of the contained resource + """ + # *LiveProperties.NAMES denotes the list of all live properties as an + # argument to the method call. + response = self.connection.getprops(self.path, + depth=1, + ns=Constants.NS_DAV, + *LiveProperties.NAMES) + result = {} + for path, properties in response.msr.items(): + if path == self.path: # omit this collection resource + continue + ## some servers do not append a trailing slash to collection paths + if self.path.endswith('/') and self.path[0:-1] == path: + continue + result[path] = LiveProperties(properties=properties) + return result + + def getCollectionContents(self): + """ + Return a list of the tuple (resources or collection) / properties) + + @return: a list of the tuple (resources or collection) / properties) + @rtype: C{list} + """ + self.validate() + collectionContents = [] + result = self.listResources() + for url, properties_ in result.items(): + if not self.path == url: + if properties_.getResourceType() == 'resource': + myWebDavStorer = ResourceStorer(url, self.connection, self.validateResourceNames) + else: + myWebDavStorer = CollectionStorer(url, self.connection, self.validateResourceNames) + collectionContents.append((myWebDavStorer, properties_)) + return collectionContents + + def findProperties(self, *names): + """ + Retrieve given properties for this collection and all directly contained resources. + + @param names: a list of property names + @return: a map from resource URI to a map from property name to value. + """ + assert isinstance(names, types.ListType) or isinstance(names, types.TupleType), \ + "Argument name has type %s" % str(type(names)) + body = createFindBody(names, self.defaultNamespace) + response = self.connection.propfind(self.path, body, depth=1) + return response.msr + + def deepFindProperties(self, *names): + """ + Retrieve given properties for this collection and all contained (nested) resources. + + Note: + ===== + This operation can take a long time if used with recursive=true and is therefore + disabled on some WebDAV servers. + + @param names: a list of property names + @return: a map from resource URI to a map from property name to value. + """ + assert isinstance(names, types.ListType.__class__) or isinstance(names, types.TupleType), \ + "Argument name has type %s" % str(type(names)) + body = createFindBody(names, self.defaultNamespace) + response = self.connection.propfind(self.path, body, depth=Constants.HTTP_HEADER_DEPTH_INFINITY) + return response.msr + + def findAllProperties(self): + """ + Retrieve all properties for this collection and all directly contained resources. + + @return: a map from resource URI to a map from property name to value. + """ + response = self.connection.allprops(self.path, depth=1) + return response.msr + + + # DASL extension + def search(self, conditions, selects): + """ + Search for contained resources which match the given search condition. + + @param conditions: tree of ConditionTerm instances representing a logical search term + @param selects: list of property names to retrieve for the found resources + """ + assert isinstance(conditions, ConditionTerm) + headers = { 'Content-Type' : XML_CONTENT_TYPE, "depth": Constants.HTTP_HEADER_DEPTH_INFINITY} + body = createSearchBody(selects, self.path, conditions) + response = self.connection._request('SEARCH', self.path, body, headers) + return response.msr + + +class LockToken(object): + """ + This class provides help on handling WebDAV lock tokens. + + @author: Roland Betz + """ + # restrict instance variables + __slots__ = ('url', 'token') + + def __init__(self, url, token): + assert isinstance(url, types.StringType) or isinstance(url, types.UnicodeType), \ + "Invalid url argument %s" % type(url) + assert isinstance(token, types.StringType) or isinstance(token, types.UnicodeType), \ + "Invalid lockToken argument %s" % type(token) + self.url = url + self.token = token + + def value(self): + """ + Descriptive string containing the lock token's URL and the token itself. + + @return: Descriptive lock token with URL. + @rtype: C{string} + """ + return "<" + self.url + "> (<" + self.token + ">)" + + def toHeader(self): + """ + Header fragment for WebDAV request. + + @return: Dictionary containing an entry for the lock token query. + @rtype: C{dictionary} + """ + return {Constants.HTTP_HEADER_IF: self.value()} + + def __str__(self): + return self.value() + + +def _blockCopyFile(source, dest, blockSize, show_progress, filesize): + """ + Copies a file in chunks of C{blockSize}. + + @param source: Source file. + @type source: FileIO buffer. + @param dest: Destination file. + @type dest: FileIO buffer. + @param blockSize: Size of block in bytes. + @type blockSize: C{int} + """ + transferedBytes = 0 + block = source.read(blockSize) + while len(block): + transferedBytes += len(block); + dest.write(block) + if show_progress: + from jarabe.journal.journalactivity import get_journal + get_journal().update_progress(transferedBytes/(filesize * 1.0)) + block = source.read(blockSize) + +def _checkUrl(url): + """ + Checks the given URL for validity. + + @param url: URL to check. + @type url: C{string} + + @raise ValueError: If the URL does not contain valid/usable content. + """ + + parts = urlsplit(url, allow_fragments=False) + if len(parts[0]) == 0 or len(parts[1]) == 0 or len(parts[2]) == 0: + raise ValueError("Invalid URL: " + repr(url)) diff --git a/src/webdav/WebdavRequests.py b/src/webdav/WebdavRequests.py new file mode 100644 index 0000000..79e586a --- /dev/null +++ b/src/webdav/WebdavRequests.py @@ -0,0 +1,205 @@ +# pylint: disable-msg=W0511,W0212,E1111 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +This module handles WebDav server requests. +""" + + +import types +from webdav import Constants +import qp_xml +from tempfile import TemporaryFile + +from davlib import XML_DOC_HEADER + +from webdav.NameCheck import validatePropertyName + + +__version__ = "$LastChangedRevision$" + + +## TODO: create a property list class + +class XmlNameSpaceMangler(object): + ''' + Handles WebDav requests. + ''' + + # restrict instance variables + __slots__ = ('shortcuts', 'defaultNameSpace') + + def __init__(self, nameList, defaultNameSpace = None): + ''' + + @param nameList: + @param defaultNameSpace: + ''' + + assert isinstance(nameList, types.ListType) or isinstance(nameList, types.TupleType), \ + "1. argument has wrong type %s" % type(nameList) + self.shortcuts = {} + self.defaultNameSpace = defaultNameSpace + for name in nameList: + if not isinstance(name, types.TupleType): + name = (defaultNameSpace, name) + assert isinstance(name, types.TupleType) and len(name) == 2, \ + "Name is not a namespace, name tuple: %s" % type(name) + validatePropertyName(name[1]) + if name[0] and not self.shortcuts.has_key(name[0]): + self.shortcuts[name[0]] = 'ns%d' % len(self.shortcuts) + + def getNameSpaces(self): + ''' + Returns the namespace. + ''' + + result = "" + for namespace, short in self.shortcuts.items(): + result += ' xmlns:%s="%s"' % (short, namespace) + return result + + def getUpdateElements(self, valueMap): + ''' + + @param valueMap: + ''' + + elements = "" + for name in valueMap.keys(): + fullname = name + if isinstance(name, types.StringType): + fullname = (self.defaultNameSpace, name) + if not fullname[0]: + tag = fullname[1] + else: + tag = self.shortcuts[fullname[0]] + ':' + fullname[1] + value = valueMap[name] + if value: + if isinstance(value, qp_xml._element): + tmpFile = TemporaryFile('w+') + value = qp_xml.dump(tmpFile, value) + tmpFile.flush() + tmpFile.seek(0) + tmpFile.readline() + value = tmpFile.read() + else: + value = "" % value + else: + value = "" + elements += "<%s>%s" % (tag, value, tag) + return elements + + def getNameElements(self, nameList): + ''' + + @param nameList: + ''' + + elements = "" + for name in nameList: + if isinstance(name, types.StringType): + name = (self.defaultNameSpace, name) + if not name[0]: + tag = name[1] + else: + tag = self.shortcuts[name[0]] + ':' + name[1] + elements += "<%s />" % tag + return elements + + + +def createUpdateBody(propertyDict, defaultNameSpace = None): + ''' + + @param propertyDict: + @param defaultNameSpace: + ''' + + updateTag = 'D:' + Constants.TAG_PROPERTY_UPDATE + setTag = 'D:' + Constants.TAG_PROPERTY_SET + propTag = 'D:' + Constants.TAG_PROP + mangler = XmlNameSpaceMangler(propertyDict.keys(), defaultNameSpace) + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s><%s %s>' % (updateTag, setTag, propTag, mangler.getNameSpaces()) + \ + mangler.getUpdateElements(propertyDict) + \ + '' % (propTag, setTag, updateTag) + + +def createDeleteBody(nameList, defaultNameSpace = None): + ''' + + @param nameList: + @param defaultNameSpace: + ''' + + updateTag = 'D:' + Constants.TAG_PROPERTY_UPDATE + removeTag = 'D:' + Constants.TAG_PROPERTY_REMOVE + propTag = 'D:' + Constants.TAG_PROP + mangler = XmlNameSpaceMangler(nameList, defaultNameSpace) + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s><%s %s>' % (updateTag, removeTag, propTag, mangler.getNameSpaces()) + \ + mangler.getNameElements(nameList) + \ + '' % (propTag, removeTag, updateTag) + + +def createFindBody(nameList, defaultNameSpace = None): + ''' + + @param nameList: + @param defaultNameSpace: + ''' + + findTag = 'D:' + Constants.TAG_PROPERTY_FIND + propTag = 'D:' + Constants.TAG_PROP + mangler = XmlNameSpaceMangler(nameList, defaultNameSpace) + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s %s>' % (findTag, propTag, mangler.getNameSpaces()) + \ + mangler.getNameElements(nameList) + \ + '' % (propTag, findTag) + + +def createSearchBody(selects, path, conditions, defaultNameSpace = None): + ''' + Creates DASL XML body. + + @param selects: list of property names to retrieve for the found resources + @param path: list of conditions + @param conditions: tree of ConditionTerm instances representing a logical search term + @param defaultNameSpace: default namespace + ''' + + searchTag = 'D:' + Constants.TAG_SEARCH_REQUEST + basicTag = 'D:' + Constants.TAG_SEARCH_BASIC + selectTag = 'D:' + Constants.TAG_SEARCH_SELECT + fromTag = 'D:' + Constants.TAG_SEARCH_FROM + scopeTag = 'D:' + Constants.TAG_SEARCH_SCOPE + whereTag = 'D:' + Constants.TAG_SEARCH_WHERE + propTag = 'D:' + Constants.TAG_PROP + hrefTag = 'D:' + Constants.TAG_HREF + depthTag = 'D:' + Constants.TAG_LOCK_DEPTH + depthValue = Constants.HTTP_HEADER_DEPTH_INFINITY + mangler = XmlNameSpaceMangler(selects, defaultNameSpace) + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s>' % (searchTag, basicTag) + \ + '<%s><%s %s>%s' % (selectTag, propTag, mangler.getNameSpaces(), + mangler.getNameElements(selects), propTag, selectTag) + \ + '<%s><%s><%s>%s<%s>%s' % (fromTag, scopeTag, hrefTag, path, hrefTag, + depthTag, depthValue, depthTag, scopeTag, fromTag) + \ + '<%s>%s' % (whereTag, conditions.toXML(),whereTag) + \ + '' % (basicTag, searchTag) + \ No newline at end of file diff --git a/src/webdav/WebdavResponse.py b/src/webdav/WebdavResponse.py new file mode 100644 index 0000000..c84943d --- /dev/null +++ b/src/webdav/WebdavResponse.py @@ -0,0 +1,525 @@ +# pylint: disable-msg=R0903,W0142,W0221,W0212,W0104,W0511,C0103,R0901 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Handles WebDAV responses. +""" + + +from davlib import _parse_status +import qp_xml +from webdav import Constants +import time +import rfc822 +import urllib +# Handling Jython 2.5 bug concerning the date pattern +# conversion in time.strptime +try: + from java.lang import IllegalArgumentException +except ImportError: + class IllegalArgumentException(object): + pass + + +__version__ = "$LastChangedRevision$" + + +class HttpStatus(object): + """ + TBD + + @ivar code: + @type code: + @ivar reason: + @type reason: + @ivar errorCount: + @type errorCount: int + """ + + def __init__(self, elem): + """ + TBD + + @param elem: ... + @type elem: instance of L{Element} + """ + self.code, self.reason = _parse_status(elem) + self.errorCount = (self.code >= Constants.CODE_LOWEST_ERROR) + def __str__(self): + return "HTTP status %d: %s" % (self.code, self.reason) + + +class MultiStatusResponse(dict): + """ + TBD + + @ivar status: + @type status: + @ivar reason: + @type reason: + @ivar errorCount: + @type errorCount: + """ + + # restrict instance variables + __slots__ = ('errorCount', 'reason', 'status') + + def __init__(self, domroot): + dict.__init__(self) + self.errorCount = 0 + self.reason = None + self.status = Constants.CODE_MULTISTATUS + if (domroot.ns != Constants.NS_DAV) or (domroot.name != Constants.TAG_MULTISTATUS): + raise ResponseFormatError(domroot, 'Invalid response: expected.') + self._scan(domroot) + + def getCode(self): + if self.errorCount == 0: + return Constants.CODE_SUCCEEDED + if len(self) > self.errorCount: + return Constants.CODE_MULTISTATUS + return self.values()[0].code + + def getReason(self): + result = "" + for response in self.values(): + if response.code > Constants.CODE_LOWEST_ERROR: + result += response.reason + return result + + def __str__(self): + result = "" + for key, value in self.items(): + if isinstance(value, PropertyResponse): + result += "Resource at %s has %d properties and %d errors.\n" % (key, len(value), value.errorCount) + else: + result += "Resource at %s returned " % key + str(value) + return result + + def _scan(self, root): + for child in root.children: + if child.ns != Constants.NS_DAV: + continue + if child.name == Constants.TAG_RESPONSEDESCRIPTION: + self.reason = child.textof() + elif child.name == Constants.TAG_RESPONSE: + self._scanResponse(child) + ### unknown child element + + def _scanResponse(self, elem): + hrefs = [] + response = None + for child in elem.children: + if child.ns != Constants.NS_DAV: + continue + if child.name == Constants.TAG_HREF: + try: + href = _unquoteHref(child.textof()) + except UnicodeDecodeError: + raise ResponseFormatError(child, "Invalid 'href' data encoding.") + hrefs.append(href) + elif child.name == Constants.TAG_STATUS: + self._scanStatus(child, *hrefs) + elif child.name == Constants.TAG_PROPERTY_STATUS: + if not response: + if len(hrefs) != 1: + raise ResponseFormatError(child, 'Invalid response: One expected.') + response = PropertyResponse() + self[hrefs[0]] = response + response._scan(child) + elif child.name == Constants.TAG_RESPONSEDESCRIPTION: + for href in hrefs: + self[href].reasons.append(child.textOf()) + ### unknown child element + if response and response.errorCount > 0: + self.errorCount += 1 + + def _scanStatus(self, elem, *hrefs): + if len(hrefs) == 0: + raise ResponseFormatError(elem, 'Invalid response: expected.') + status = HttpStatus(elem) + for href in hrefs: + self[href] = status + if status.errorCount: + self.errorCount += 1 + + # Instance properties + code = property(getCode, None, None, "HTTP response code") + + + +class PropertyResponse(dict): + """ + TBD + + @ivar errors: + @type errors: list of ... + @ivar reasons: + @type reasons: list of ... + @ivar failedProperties: + @type failedProperties: dict of ... + """ + + # restrict instance variables + __slots__ = ('errors', 'reasons', 'failedProperties') + + def __init__(self): + dict.__init__(self) + self.errors = [] + self.reasons = [] + self.failedProperties = {} + + def __str__(self): + result = "" + for value in self.values(): + result += value.name + '= ' + value.textof() + '\n' + result += self.getReason() + return result + + def getCode(self): + if len(self.errors) == 0: + return Constants.CODE_SUCCEEDED + if len(self) > 0: + return Constants.CODE_MULTISTATUS + return self.errors[-1].code + + def getReason(self): + result = "" + if len(self.errors) > 0: + result = "Failed for: " + repr(self.failedProperties.keys()) + "\n" + for error in self.errors: + result += "%s (%d). " % (error.reason, error.code) + for reason in self.reasons: + result += "%s. " % reason + return result + + def _scan(self, element): + status = None + statusElement = element.find(Constants.TAG_STATUS, Constants.NS_DAV) + if statusElement: + status = HttpStatus(statusElement) + if status.errorCount: + self.errors.append(status) + + propElement = element.find(Constants.TAG_PROP, Constants.NS_DAV) + if propElement: + for prop in propElement.children: + if status.errorCount: + self.failedProperties[(prop.ns, prop.name)]= status + else: + prop.__class__ = Element # bad, bad trick + self[prop.fullname] = prop + reasonElement = element.find(Constants.TAG_RESPONSEDESCRIPTION, Constants.NS_DAV) + if reasonElement: + self.reasons.append(reasonElement.textOf()) + + # Instance properties + code = property(getCode, None, None, "HTTP response code") + errorCount = property(lambda self: len(self.errors), None, None, "HTTP response code") + reason = property(getReason, None, None, "HTTP response code") + + + + +class LiveProperties(object): + """ + This class provides convenient access to the WebDAV 'live' properties of a resource. + WebDav 'live' properties are defined in RFC 2518, Section 13. + Each property is converted from string to its natural data type. + + @version: $Revision$ + @author: Roland Betz + """ + + # restrict instance variables + __slots__ = ('properties') + + NAMES = (Constants.PROP_CREATION_DATE, Constants.PROP_DISPLAY_NAME, + Constants.PROP_CONTENT_LENGTH, Constants.PROP_CONTENT_TYPE, Constants.PROP_ETAG, + Constants.PROP_LAST_MODIFIED, Constants.PROP_OWNER, + Constants.PROP_LOCK_DISCOVERY, Constants.PROP_RESOURCE_TYPE, Constants.PROP_SUPPORTED_LOCK ) + + def __init__(self, properties=None, propElement=None): + """ + Construct StandardProperties from a map of properties containing + live properties or from a XML 'prop' element containing live properties + + @param properties: map as implemented by class L{PropertyResponse} + @param propElement: an C{Element} value + """ + assert isinstance(properties, PropertyResponse) or \ + isinstance(propElement, qp_xml._element), \ + "Argument properties has type %s" % str(type(properties)) + self.properties = {} + for name, value in properties.items(): + if name[0] == Constants.NS_DAV and name[1] in self.NAMES: + self.properties[name[1]] = value + + def getContentLanguage(self): + """ + Return the language of a resource's textual content or null + + @return: string + """ + + result = "" + if not self.properties.get(Constants.PROP_CONTENT_LANGUAGE, None) is None: + result = self.properties.get(Constants.PROP_CONTENT_LANGUAGE).textof() + return result + + def getContentLength(self): + """ + Returns the length of the resource's content in bytes. + + @return: number of bytes + """ + + result = 0 + if not self.properties.get(Constants.PROP_CONTENT_LENGTH, None) is None: + result = int(self.properties.get(Constants.PROP_CONTENT_LENGTH).textof()) + return result + + def getContentType(self): + """ + Return the resource's content MIME type. + + @return: MIME type string + """ + + result = "" + if not self.properties.get(Constants.PROP_CONTENT_TYPE, None) is None: + result = self.properties.get(Constants.PROP_CONTENT_TYPE).textof() + return result + + def getCreationDate(self): + """ + Return date of creation as time tuple. + + @return: time tuple + @rtype: C{time.struct_time} + + @raise ValueError: If string is not in the expected format (ISO 8601). + """ + + datetimeString = "" + if not self.properties.get(Constants.PROP_CREATION_DATE, None) is None: + datetimeString = self.properties.get(Constants.PROP_CREATION_DATE).textof() + + result = rfc822.parsedate(datetimeString) + if result is None: + result = _parseIso8601String(datetimeString) + + return time.mktime(result) + + def getEntityTag(self): + """ + Return a entity tag which is unique for a particular version of a resource. + Different resources or one resource before and after modification have different etags. + + @return: entity tag string + """ + + result = "" + if not self.properties.get(Constants.PROP_ETAG, None) is None: + result = self.properties.get(Constants.PROP_ETAG).textof() + return result + + def getDisplayName(self): + """ + Returns a resource's display name. + + @return: string + """ + + result = "" + if not self.properties.get(Constants.PROP_DISPLAY_NAME, None) is None: + result = self.properties.get(Constants.PROP_DISPLAY_NAME).textof() + return result + + def getLastModified(self): + """ + Return last modification of resource as time tuple. + + @return: Modification date time. + @rtype: C{time.struct_time} + + @raise ValueError: If the date time string is not in the expected format (RFC 822 / ISO 8601). + """ + + datetimeString = None + if not self.properties.get(Constants.PROP_LAST_MODIFIED, None) is None: + datetimeString = self.properties.get(Constants.PROP_LAST_MODIFIED).textof() + result = rfc822.parsedate(datetimeString) + if result is None: + result = _parseIso8601String(datetimeString) + return time.mktime(result) + + def getLockDiscovery(self): + """ + Return all current lock's applied to a resource or null if it is not locked. + + @return: a lockdiscovery DOM element according to RFC 2815 + """ + + xml = self.properties.get(Constants.PROP_LOCK_DISCOVERY) + return _scanLockDiscovery(xml) + + def getResourceType(self): + """ + Return a resource's WebDAV type. + + @return: 'collection' or 'resource' + """ + + xml = self.properties.get(Constants.PROP_RESOURCE_TYPE) + if xml and xml.children: + return xml.children[0].name + return "resource" + + def getSupportedLock(self): + """ + Return a DOM element describing all supported lock options for a resource. + Usually this is shared and exclusive write lock. + + @return: supportedlock DOM element according to RFC 2815 + """ + + xml = self.properties.get(Constants.PROP_SUPPORTED_LOCK) + return xml + + def getOwnerAsUrl(self): + """ + Return a resource's owner in form of a URL. + + @return: string + """ + + xml = self.properties.get(Constants.PROP_OWNER) + if xml and len(xml.children): + return xml.children[0].textof() + return None + + def __str__(self): + result = "" + result += " Name=" + self.getDisplayName() + result += "\n Type=" + self.getResourceType() + result += "\n Length=" + str(self.getContentLength()) + result += "\n Content Type="+ self.getContentType() + result += "\n ETag=" + self.getEntityTag() + result += "\n Created=" + time.strftime("%c GMT", self.getCreationDate()) + result += "\n Modified=" + time.strftime("%c GMT", self.getLastModified()) + return result + + +def _parseIso8601String(date): + """ + Parses the given ISO 8601 string and returns a time tuple. + The strings should be formatted according to RFC 3339 (see section 5.6). + But currently there are two exceptions: + 1. Time offset is limited to "Z". + 2. Fragments of seconds are ignored. + """ + + if "." in date and "Z" in date: # Contains fragments of second? + secondFragmentPos = date.rfind(".") + timeOffsetPos = date.rfind("Z") + date = date[:secondFragmentPos] + date[timeOffsetPos:] + try: + timeTuple = time.strptime(date, Constants.DATE_FORMAT_ISO8601) + except IllegalArgumentException: # Handling Jython 2.5 bug concerning the date pattern accordingly + import _strptime # Using the Jython fall back solution directly + timeTuple = _strptime.strptime(date, Constants.DATE_FORMAT_ISO8601) + return timeTuple + + +class ResponseFormatError(IOError): + """ + An instance of this class is raised when the web server returned a webdav + reply which does not adhere to the standard and cannot be recognized. + """ + def __init__(self, element, message= None): + IOError.__init__(self, "ResponseFormatError at element %s: %s" % (element.name, message)) + self.element = element + self.message = message + + +class Element(qp_xml._element): + """ + This class improves the DOM interface (i.e. element interface) provided by the qp_xml module + TODO: substitute qp_xml by 'real' implementation. e.g. domlette + """ + def __init__(self, namespace, name, cdata=''): + qp_xml._element.__init__(self, ns=namespace, name=name, lang=None, parent=None, + children=[], ns_scope={}, attrs={}, + first_cdata=cdata, following_cdata='') + + def __str__(self): + return self.textof() + + def __getattr__(self, name): + if (name == 'fullname'): + return (self.__dict__['ns'], self.__dict__['name']) + raise AttributeError, name + + def add(self, child): + self.children.append(child) + return child + +def _scanLockDiscovery(root): + assert root.name == Constants.PROP_LOCK_DISCOVERY, "Invalid lock discovery XML element" + active = root.find(Constants.TAG_ACTIVE_LOCK, Constants.NS_DAV) + if active: + return _scanActivelock(active) + return None + +def _scanActivelock(root): + assert root.name == Constants.TAG_ACTIVE_LOCK, "Invalid active lock XML element" + token = _scanOrError(root, Constants.TAG_LOCK_TOKEN) + value = _scanOrError(token, Constants.TAG_HREF) + owner = _scanOwner(root) + depth = _scanOrError(root, Constants.TAG_LOCK_DEPTH) + return (value.textof(), owner, depth.textof()) + +def _scanOwner(root): + owner = root.find(Constants.TAG_LOCK_OWNER, Constants.NS_DAV) + if owner: + href = owner.find(Constants.TAG_HREF, Constants.NS_DAV) + if href: + return href.textof() + return owner.textof() + return None + +def _scanOrError(elem, childName): + child = elem.find(childName, Constants.NS_DAV) + if not child: + raise ResponseFormatError(elem, "Invalid response: <"+childName+"> expected") + return child + + +def _unquoteHref(href): + #print "*** Response HREF=", repr(href) + if type(href) == type(u""): + try: + href = href.encode('ascii') + except UnicodeError: # URL contains unescaped non-ascii character + # handle bug in Tamino webdav server + return urllib.unquote(href) + href = urllib.unquote(href) + if Constants.CONFIG_UNICODE_URL: + return unicode(href, 'utf-8') + else: + return unicode(href, 'latin-1') diff --git a/src/webdav/__init__.py b/src/webdav/__init__.py new file mode 100644 index 0000000..3e46609 --- /dev/null +++ b/src/webdav/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +__version__ = "$LastChangedRevision$" diff --git a/src/webdav/acp/Ace.py b/src/webdav/acp/Ace.py new file mode 100644 index 0000000..8321d41 --- /dev/null +++ b/src/webdav/acp/Ace.py @@ -0,0 +1,293 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +ACE object handling according to WebDAV ACP specification. +""" + + +from webdav.acp.Principal import Principal +from webdav.acp.GrantDeny import GrantDeny +from webdav import Constants +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +class ACE(object): + """ + This class provides functionality for handling ACEs + + @ivar principal: A principal (user or group) + @type principal: L{Principal} object + @ivar invert: Flag indicating whether ACE should invert the principal. + @type invert: C{bool} + @ivar grantDenies: Grant or deny clauses for privileges + @type grantDenies: C{list} of L{GrantDeny} objects + @ivar protected: Flag indicating whether ACE is protected. + @type protected: C{bool} + @ivar inherited: URL indicating the source from where the ACE is inherited. + @type inherited: C{string} + """ + + # restrict instance variables + __slots__ = ('principal', 'invert', 'grantDenies', 'protected', 'inherited') + + def __init__(self, domroot=None, principal=None, grantDenies=None): + """ + Constructor should be called with either no parameters (create blank ACE), + one parameter (a DOM tree or principal), or two parameters (principal and + sequence of GrantDenies). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + @param principal: A principal (user or group), (default: None). + @type principal: L{Principal} object + @param grantDenies: Grant and deny clauses for privileges (default: None). + @type grantDenies: sequence of L{GrantDeny} objects + + @raise WebdavError: When non-valid parameters are passed a L{WebdavError} is raised. + """ + self.principal = Principal() + self.protected = None + self.inherited = None + self.invert = None + self.grantDenies = [] + + if domroot: + self.principal = Principal(domroot=domroot.find(Constants.TAG_PRINCIPAL, Constants.NS_DAV)) + self.inherited = domroot.find(Constants.TAG_INHERITED, Constants.NS_DAV) + if self.inherited: + self.inherited = self.inherited.children[0].textof() + if domroot.find(Constants.TAG_PROTECTED, Constants.NS_DAV): + self.protected = 1 + for child in domroot.children: + if child.ns == Constants.NS_DAV \ + and (child.name == Constants.TAG_GRANT or child.name == Constants.TAG_DENY): + self.grantDenies.append(GrantDeny(domroot=child)) + elif isinstance(principal, Principal): + newPrincipal = Principal() + newPrincipal.copy(principal) + self.principal = newPrincipal + if (isinstance(grantDenies, list) or isinstance(grantDenies, tuple)): + self.addGrantDenies(grantDenies) + elif domroot == None and grantDenies == None: + # no param ==> blank ACE + pass + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('non-valid parameters handed to ACE constructor') + + def __cmp__(self, other): + if not isinstance(other, ACE): + return 1 + if self.principal == other.principal \ + and self.invert == other.invert \ + and self.protected == other.protected \ + and self.inherited == other.inherited: + equal = 1 + for grantDeny in self.grantDenies: + inList = 0 + for otherGrantDeny in other.grantDenies: + if grantDeny == otherGrantDeny: + inList = 1 + if inList == 0: + equal = 0 + return not equal + else: + return 1 + + def __repr__(self): + repr = '' % (repr) + + def copy(self, other): + '''Copy an ACE object. + + @param other: Another ACE to copy. + @type other: L{ACE} object + + @raise WebdavError: When an object that is not an L{ACE} is passed + a L{WebdavError} is raised. + ''' + if not isinstance(other, ACE): + raise WebdavError('Non-ACE object passed to copy method: %s.' % other.__class__) + self.invert = other.invert + self.protected = other.protected + self.inherited = other.inherited + self.principal = Principal() + if other.principal: + self.principal.copy(other.principal) + if other.grantDenies: + self.addGrantDenies(other.grantDenies) + + def isValid(self): + """ + Returns true/false (1/0) whether necessarry props + principal and grantDenies are set and whether the ACE contains one + grant or deny clauses. + + @return: Validity of ACE. + @rtype: C{bool} + """ + return self.principal and len(self.grantDenies) == 1 + + def isGrant(self): + ''' + Returns true/false (1/0) if ACE contains only grant clauses. + + @return: Value whether the ACE is of grant type. + @rtype: C{bool} + ''' + if self.isMixed() or len(self.grantDenies) < 1: + return 0 + else: + return self.grantDenies[0].isGrant() + + def isDeny(self): + ''' + Returns true/false (1/0) if ACE contains only deny clauses. + + @return: Value whether the ACE is of deny type. + @rtype: C{bool} + ''' + if self.isMixed() or len(self.grantDenies) < 1: + return 0 + else: + return self.grantDenies[0].isDeny() + + def isMixed(self): + ''' + Returns true/false (1/0) if ACE contains both types (grant and deny) of clauses. + + @return: Value whether the ACE is of mixed (grant and deny) type. + @rtype: C{bool} + ''' + mixed = 0 + if len(self.grantDenies): + first = self.grantDenies[0].grantDeny + for grantDeny in self.grantDenies: + if grantDeny.grantDeny != first: + mixed = 1 + return mixed + + def toXML(self, defaultNameSpace=None): + """ + Returns ACE content as a string of valid XML as described in WebDAV ACP. + + @param defaultNameSpace: Name space (default: None). + @type defaultNameSpace: C(string) + """ + assert self.isValid(), "ACE is not initialized or does not contain valid content!" + + ACE = 'D:' + Constants.TAG_ACE + res = self.principal.toXML(self.invert) + for grantDeny in self.grantDenies: + res += grantDeny.toXML() + if self.protected: + res += '' + if self.inherited: + res += '%s' % (self.inherited) + return '<%s>%s' % (ACE, res, ACE) + + def setPrincipal(self, principal): + ''' + Sets the passed principal on the ACE. + + @param principal: A principal. + @type principal: L{Principal} object + ''' + self.principal = Principal() + self.principal.copy(principal) + + def setInherited(self, href): + ''' + Sets the passed URL on the ACE to denote from where it is inherited. + + @param href: A URL. + @type href: C{string} + ''' + self.inherited = href + + def addGrantDeny(self, grantDeny): + ''' + Adds the passed GrantDeny object to list if it's not in it, yet. + + @param grantDeny: A grant or deny clause. + @type grantDeny: L{GrantDeny} object + ''' + # only add it if it's not in the list, yet ... + inList = 0 + for element in self.grantDenies: + if element == grantDeny: + inList = 1 + if not inList: + newGrantDeny = GrantDeny() + newGrantDeny.copy(grantDeny) + self.grantDenies.append(newGrantDeny) + + def addGrantDenies(self, grantDenies): + '''Adds the list of passed grant/deny objects to list. + + @param grantDenies: Grant or deny clauses. + @type grantDenies: sequence of L{GrantDeny} objects + ''' + map(lambda grantDeny: self.addGrantDeny(grantDeny), grantDenies) + + def delGrantDeny(self, grantDeny): + '''Deletes the passed GrantDeny object from list. + + @param grantDeny: A grant or deny clause. + @type grantDeny: L{GrantDeny} object + + @raise WebdavError: A L{WebdavError} is raised if the clause to be + deleted is not present. + ''' + # only add it if it's not in the list, yet ... + count = 0 + index = 0 + for element in self.grantDenies: + count += 1 + if element == grantDeny: + index = count + if index: + self.grantDenies.pop(index - 1) + else: + raise WebdavError('GrantDeny to be deleted not in list: %s.' % grantDeny) + + def delGrantDenies(self, grantDenies): + '''Deletes the list of passed grant/deny objects from list. + + @param grantDenies: Grant or deny clauses. + @type grantDenies: sequence of L{GrantDeny} objects + ''' + map(lambda grantDeny: self.delGrantDeny(grantDeny), grantDenies) diff --git a/src/webdav/acp/AceHandler.py b/src/webdav/acp/AceHandler.py new file mode 100644 index 0000000..e07b74d --- /dev/null +++ b/src/webdav/acp/AceHandler.py @@ -0,0 +1,182 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Handling of WebDAV Access Protocol Extensions and ACL preparation for UI. +""" + + +from webdav import Constants +from webdav.WebdavClient import ResourceStorer +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +def extractSupportedPrivilegeSet(userPrivileges): + """ + Returns a dictionary of supported privileges. + + @param userPrivileges: A DOM tree. + @type userPrivileges: L{webdav.WebdavResponse.Element} object + + @raise WebdavError: When unknown elements appear in the + C{DAV:supported-privilege} appear a L{WebdavError} is raised. + + @return: A dictionary with privilege names as keys and privilege descriptions as values. + @rtype: C{dictionary} + """ + result = {} + for element in userPrivileges.children: + if element.name == Constants.TAG_SUPPORTED_PRIVILEGE: + privName = '' + privDescription = '' + for privilege in element.children: + if privilege.name == Constants.TAG_PRIVILEGE: + privName = privilege.children[0].name + elif privilege.name == Constants.TAG_DESCRIPTION: + privDescription = privilege.textof() + else: + raise WebdavError('Unknown element in DAV:supported-privilege: ' + privilege.name) + + if privName and privDescription: + result[privName] = privDescription + privName = '' + privDescription = '' + else: + raise WebdavError('Invalid element tag in DAV:supported-privilege-set: ' + element.name) + return result + + +def _insertAclDisplaynames(acl): + """ + Modifies the ACL by adding the human readable names + (DAV:displayname property) of each principal found in an ACL. + + This should be done with the REPORT method, but it is not supported by + Jacarta Slide, yet. (As of Aug. 1, 2003 in CVS repository) + + So we are going to do it differently by foot the harder way ... + + @param acl: An ACL object for which the displaynames should be retrieved. + @type acl: L{ACL} object + """ + ## This is redundant code to be still kept for the REPORT method way of doing it ... + ## property = '''''' + ## return self.getReport(REPORT_ACL_PRINCIPAL_PROP_SET, property) + for ace in acl.aces: + if not ace.principal.property: + principalConnection = \ + ResourceStorer(ace.principal.principalURL) + ace.principal.displayname = \ + principalConnection.readProperty(Constants.NS_DAV, Constants.PROP_DISPLAY_NAME) + + +def prepareAcls(acls): + """ + Returns all ACLs describing the behaviour of the resource. The information + in the ACL is modified to contain all information needed to display in the UI. + + @param acls: ACL objects. + @type acls: C{list} of L{ACL} objects + + @return: (non-valid) ACLs that contain both grant and deny clauses in an ACE. + Displaynames are added to the Principals where needed. + @rtype: C{list} of L{ACL} objects + """ + for acl in acls.keys(): + acls[acl] = acls[acl].joinGrantDeny() + _insertAclDisplaynames(acls[acl]) + return acls + + +def prepareAcl(acl): + """ + Returns an ACL describing the behaviour of the resource. The information + in the ACL is modified to contain all information needed to display in the UI. + + @param acl: An ACL object. + @type acl: L{ACL} object + + @return: A (non-valid) ACL that contains both grant and deny clauses in an ACE. + Displaynames are added to the Principals where needed. + @rtype: L{ACL} object + """ + acl = acl.joinGrantDeny() + _insertAclDisplaynames(acl) + return acl + + +def refineAclForSet(acl): + """ + Sets the ACL composed from the UI on the WebDAV server. For that purpose the + ACL object gets refined first to form a well accepted ACL to be set by the + ACL WebDAV method. + + @param acl: An ACL object to be refined. + @type acl: L{ACL} object + + @return: A valid ACL that contains only grant or deny clauses in an ACE. + Inherited and protected ACEs are stripped out. + @rtype: L{ACL} object + """ + acl = acl.splitGrantDeny() + acl = acl.stripAces() + return acl + + +##~ unsupported or unfinished methods: +##~ +##~ def report(self, report, request=None, lockToken=None): +##~ """ +##~ This method implements the WebDAV ACP method: REPORT for given report +##~ types. +##~ +##~ Parameters: +##~ +##~ 'report' -- Report type as a string. +##~ +##~ 'request' -- XML content of the request for the report (defaults to None). +##~ +##~ 'lockToken' -- Lock token to be set (defaults to None). +##~ """ +##~ raise WebdavError('Reports are not supported by our Jacarta Slide, yet (as of Aug. 1, 2003 in CVS).') +##~ +##~ headers = createCondition(lockToken) +##~ headers['Content-Type'] = XML_CONTENT_TYPE +##~ body = '%s' % (report, request, report) +##~ #print "Body: ", body +##~ response = self.connection._request('REPORT', self.path, body, headers) +##~ return response +##~ ## TODO: parse DAV:error response +##~ +##~ +##~ def getAllAcls(self): +##~ """ +##~ Returns a dictionary of ACL resources with respective ACL objects +##~ that apply to the given resource. +##~ +##~ ### This method needs to be extended for inherited ACLs when Tamino +##~ support tells me (Guy) how to get to them. +##~ """ +##~ acls = {self.path: self.getAcl()} +##~ for ace in acls[self.path].aces: +##~ if ace.inherited: +##~ if not ace.inherited in acls: +##~ acls[ace.inherited] = self.getAcl() +##~ +##~ # append some more stuff here to acls for possible inherited ACLs +##~ return acls diff --git a/src/webdav/acp/Acl.py b/src/webdav/acp/Acl.py new file mode 100644 index 0000000..8f2b36f --- /dev/null +++ b/src/webdav/acp/Acl.py @@ -0,0 +1,311 @@ +# pylint: disable-msg=W0622 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + + +""" +ACL object handling according to WebDAV ACP specification. +""" + + +from webdav.acp.Ace import ACE +from webdav import Constants +from webdav.Connection import WebdavError +from webdav.davlib import XML_DOC_HEADER + + +__version__ = "$LastChangedRevision$" + + +class ACL(object): + """ + This class provides access to Access Control List funcionality + as specified in the WebDAV ACP. + + @ivar aces: ACEs in ACL + @type aces: C{list} of L{ACE} objects + @ivar withInherited: Flag indicating whether ACL contains inherited ACEs. + @type withInherited: C{bool} + """ + + # restrict instance variables + __slots__ = ('aces', 'withInherited') + + def __init__(self, domroot=None, aces=None): + """ + Constructor should be called with either no parameters (create blank ACE), + or one parameter (a DOM tree or ACE list). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + @param aces: ACE objects (default: None) + @type aces: C{list} of L{ACE} objects + + @raise WebdavError: When non-valid parameters are passed a L{WebdavError} is raised. + """ + self.withInherited = None + self.aces = [] + + if domroot: + for child in domroot.children: + if child.name == Constants.TAG_ACE and child.ns == Constants.NS_DAV: + self.addAce(ACE(child)) + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('Non-ACE tag handed to ACL constructor: ' + child.ns + child.name) + elif isinstance(aces, list) or isinstance(aces, tuple): + self.addAces(aces) + elif domroot == None and aces == None: + # no param ==> blank object + pass + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('non-valid parameters handed to ACL constructor') + + def __cmp__(self, other): + if not isinstance(other, ACL): + return 1 + if self.withInherited == other.withInherited: + equal = 1 + for ace in self.aces: + inList = 0 + for otherAce in other.aces: + if ace == otherAce: + inList = 1 + if inList == 0: + equal = 0 + return not equal + else: + return 1 + + def __repr__(self): + repr = '' % (repr) + + def copy(self, other): + '''Copy an ACL object. + + @param other: Another ACL to copy. + @type other: L{ACL} object + + @raise WebdavError: When an object that is not an L{ACL} is passed + a L{WebdavError} is raised. + ''' + if not isinstance(other, ACL): + raise WebdavError('Non-ACL object passed to copy method: %s' % other.__class__) + self.withInherited = other.withInherited + if other.aces: + self.addAces(other.aces) + + def toXML(self): + """ + Returns ACL content as a string of valid XML as described in WebDAV ACP. + """ + aclTag = 'D:' + Constants.TAG_ACL + return XML_DOC_HEADER +\ + '<' + aclTag + ' xmlns:D="DAV:">' + reduce(lambda xml, ace: xml + ace.toXML() + '\n', [''] + self.aces) +\ + '' + + def addAce(self, ace): + ''' + Adds the passed ACE object to list if it's not in it, yet. + + @param ace: An ACE. + @type ace: L{ACE} object + ''' + newAce = ACE() + newAce.copy(ace) + # only add it if it's not in the list, yet ... + inList = 0 + for element in self.aces: + if element == ace: + inList = 1 + if not inList: + self.aces.append(newAce) + + def addAces(self, aces): + '''Adds the list of passed ACE objects to list. + + @param aces: ACEs + @type aces: sequence of L{ACE} objects + ''' + for ace in aces: + self.addAce(ace) + + def delAce(self, ace): + '''Deletes the passed ACE object from list. + + @param ace: An ACE. + @type ace: L{ACE} object + + @raise WebdavError: When the ACE to be deleted is not within the ACL + a L{WebdavError} is raised. + ''' + # find where it is and delete it ... + count = 0 + index = 0 + for element in self.aces: + count += 1 + if element == ace: + index = count + if index: + self.aces.pop(index - 1) + else: + raise WebdavError('ACE to be deleted not in list: %s.' % ace) + + def delAces(self, aces): + '''Deletes the list of passed ACE objects from list. + + @param aces: ACEs + @type aces: sequence of L{ACE} objects + ''' + for ace in aces: + self.delAce(ace) + + def delPrincipalsAces(self, principal): + """ + Deletes all ACEs in ACL by given principal. + + @param principal: A principal. + @type principal: L{Principal} object + """ + # find where it is and delete it ... + index = 0 + while index < len(self.aces): + if self.aces[index].principal.principalURL == principal.principalURL: + self.aces.pop(index) + else: + index += 1 + + def joinGrantDeny(self): + """ + Returns a "refined" ACL of the ACL for ease of use in the UI. + The purpose is to post the user an ACE that can contain both, granted + and denied, privileges. So possible pairs of grant and deny ACEs are joined + to return them in one ACE. This resulting ACE then of course IS NOT valid + for setting ACLs anymore. They will have to be reconverted to yield valid + ACLs for the ACL method. + + @return: A (non-valid) ACL that contains both grant and deny clauses in an ACE. + @rtype: L{ACL} object + """ + joinedAces = {} + for ace in self.aces: + if not ace.principal.principalURL is None: + principalKey = ace.principal.principalURL + elif not ace.principal.property is None: + principalKey = ace.principal.property + else: + principalKey = None + if ace.inherited: + principalKey = ace.inherited + ":" + principalKey + if principalKey in joinedAces: + joinedAces[principalKey].addGrantDenies(ace.grantDenies) + else: + joinedAces[principalKey] = ACE() + joinedAces[principalKey].copy(ace) + newAcl = ACL() + newAcl.addAces(joinedAces.values()) + return newAcl + + def splitGrantDeny(self): + """ + Returns a "refined" ACL of the ACL for ease of use in the UI. + The purpose is to post the user an ACE that can contain both, granted + and denied, privileges. So possible joined grant and deny clauses in ACEs + splitted to return them in separate ACEs. This resulting ACE then is valid + for setting ACLs again. This method is to be seen in conjunction with the + method joinGrantDeny as it reverts its effect. + + @return: A valid ACL that contains only ACEs with either grant or deny clauses. + @rtype: L{ACL} object + """ + acesGrant = {} + acesDeny = {} + for ace in self.aces: + for grantDeny in ace.grantDenies: + if grantDeny.isGrant(): + if ace.principal.principalURL in acesGrant: + ace.addGrantDeny(grantDeny) + else: + acesGrant[ace.principal.principalURL] = ACE() + acesGrant[ace.principal.principalURL].copy(ace) + acesGrant[ace.principal.principalURL].grantDenies = [] + acesGrant[ace.principal.principalURL].addGrantDeny(grantDeny) + else: + if ace.principal.principalURL in acesDeny: + ace.addGrantDeny(grantDeny) + else: + acesDeny[ace.principal.principalURL] = ACE() + acesDeny[ace.principal.principalURL].copy(ace) + acesDeny[ace.principal.principalURL].grantDenies = [] + acesDeny[ace.principal.principalURL].addGrantDeny(grantDeny) + newAcl = ACL() + newAcl.addAces(acesGrant.values()) + newAcl.addAces(acesDeny.values()) + return newAcl + + def isValid(self): + """ + Returns true (1) if all contained ACE objects are valid, + otherwise false (0) is returned. + + @return: Validity of ACL. + @rtype: C{bool} + """ + valid = 1 + if len(self.aces): + for ace in self.aces: + if not ace.isValid(): + valid = 0 + return valid + + def stripAces(self, inherited=True, protected=True): + """ + Returns an ACL object with all ACEs stripped that are inherited + and/or protected. + + @param inherited: Flag to indicate whether inherited ACEs should + be stripped (default: True). + @type inherited: C{bool} + @param protected: Flag to indicate whether protected ACEs should + be stripped (default: True). + @type protected: C{bool} + + @return: An ACL without the stripped ACEs. + @rtype: L{ACL} object + """ + newAcl = ACL() + if len(self.aces): + for ace in self.aces: + keep = 1 + if inherited and ace.inherited: + keep = 0 + elif protected and ace.protected: + keep = 0 + if keep: + newAcl.addAce(ace) + return newAcl diff --git a/src/webdav/acp/GrantDeny.py b/src/webdav/acp/GrantDeny.py new file mode 100644 index 0000000..52c9b93 --- /dev/null +++ b/src/webdav/acp/GrantDeny.py @@ -0,0 +1,241 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Handling of grant and deny clauses in ACEs according to WebDAV ACP specification. +""" + + +from webdav.acp.Privilege import Privilege +from webdav import Constants +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +class GrantDeny(object): + """ + This class provides functionality for handling + grant and deny clauses in ACEs. + + @ivar grantDeny: Flag indicating whether clause grants or denies. + @type grantDeny: C{bool} + @ivar privileges: Privileges to be granted or denied. + @type privileges: C{list} of L{Privilege} objects + """ + + def __init__(self, domroot=None): + """ + Constructor should be called with either no parameters + (create blank GrantDeny), or one parameter (a DOM tree). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + + @raise WebdavError: When non-valid parameters are passed a L{WebdavError} is raised. + """ + self.grantDeny = 0 # 0: deny, 1: grant + self.privileges = [] + + if domroot: + self.grantDeny = (domroot.name == Constants.TAG_GRANT) + for child in domroot.children: + if child.name == Constants.TAG_PRIVILEGE and child.ns == Constants.NS_DAV: + self.privileges.append(Privilege(domroot=child)) + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('Non-privilege tag handed to GrantDeny constructor: %s' \ + % child.name) + elif domroot == None: + # no param ==> blank object + pass + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('Non-valid parameters handed to GrantDeny constructor.') + + def __cmp__(self, other): + """ Compares two GrantDeny instances. """ + if not isinstance(other, GrantDeny): + return 1 + if self.grantDeny == other.grantDeny: + equal = 1 + for priv in self.privileges: + inList = 0 + for otherPriv in other.privileges: + if priv == otherPriv: + inList = 1 + if inList == 0: + equal = 0 + return not equal + else: + return 1 + + def __repr__(self): + """ Returns the representation of an instance. """ + representation = '' % (representation) + + def copy(self, other): + """ + Copy a GrantDeny object. + + @param other: Another grant or deny clause to copy. + @type other: L{GrantDeny} object + + @raise WebdavError: When an object that is not an L{GrantDeny} is passed + a L{WebdavError} is raised. + """ + if not isinstance(other, GrantDeny): + raise WebdavError('Non-GrantDeny object passed to copy method: %s' \ + % other) + self.grantDeny = other.grantDeny + if other.privileges: + self.addPrivileges(other.privileges) + + def isGrant(self): + """ + Returns whether the set of privileges is of type "grant" + indicating true or false. + + @return: Value whether the clause is of grant type. + @rtype: C{bool} + """ + return self.grantDeny + + def isDeny(self): + """ + Returns whether the set of privileges is of type "deny" + indicating true or false. + + @return: Value whether the clause is of deny type. + @rtype: C{bool} + """ + return not self.grantDeny + + def setGrantDeny(self, grantDeny): + """ + Sets the set of privileges to given value for grantDeny. + + @param grantDeny: Grant/deny value for clause (grant: True/1, deny: False/0). + @type grantDeny: C{bool} + """ + if grantDeny == 0 or grantDeny == 1: + self.grantDeny = grantDeny + + def setGrant(self): + """ Sets the set of privileges to type "grant". """ + self.grantDeny = 1 + + def setDeny(self): + """ Sets the set of privileges to type "deny". """ + self.grantDeny = 0 + + def isAll(self): + """ + Checks whether the privileges contained are equal + to aggregate DAV:all privilege. + + @return: Value whether all un-aggregated privileges are present. + @rtype: C{bool} + """ + + if len(self.privileges) == 1 and self.privileges[0].name == Constants.TAG_ALL: + return 1 + return 0 + + def addPrivilege(self, privilege): + """ + Adds the passed privilege to list if it's not in it, yet. + + @param privilege: A privilege. + @type privilege: L{Privilege} object + """ + inList = False + for priv in self.privileges: + if priv == privilege: + inList = True + if not inList: + newPrivilege = Privilege() + newPrivilege.copy(privilege) + self.privileges.append(newPrivilege) + + def addPrivileges(self, privileges): + """ + Adds the list of passed privileges to list. + + @param privileges: Several privileges. + @type privileges: sequence of L{Privilege} objects + """ + for priv in privileges: + self.addPrivilege(priv) + + def delPrivilege(self, privilege): + """ + Deletes the passed privilege from list if it's in it. + + @param privilege: A privilege. + @type privilege: L{Privilege} object + + @raise WebdavError: A L{WebdavError} is raised if the privilege to be + deleted is not present. + """ + count = 0 + index = 0 + for priv in self.privileges: + count += 1 + if priv == privilege: + index = count + if index: + self.privileges.pop(index - 1) + else: + raise WebdavError('Privilege to be deleted not in list: %s' % privilege) + + def delPrivileges(self, privileges): + """ + Deletes the list of passed privileges from list. + + @param privileges: Several privileges. + @type privileges: sequence of L{Privilege} objects + """ + for priv in privileges: + self.delPrivilege(priv) + + def toXML(self): + """ + Returns string of GrantDeny content to valid XML as described in WebDAV ACP. + """ + assert self.privileges, "GrantDeny object is not initialized or does not contain content!" + + if self.isGrant(): + tag = 'D:' + Constants.TAG_GRANT + else: + tag = 'D:' + Constants.TAG_DENY + + res = '' + for privilege in self.privileges: + res += privilege.toXML() + return '<%s>%s' % (tag, res, tag) diff --git a/src/webdav/acp/Makefile.am b/src/webdav/acp/Makefile.am new file mode 100644 index 0000000..506eb92 --- /dev/null +++ b/src/webdav/acp/Makefile.am @@ -0,0 +1,12 @@ +sugardir = $(pythondir)/webdav/acp +sugar_PYTHON = \ + AceHandler.py \ + Ace.py \ + Acl.py \ + GrantDeny.py \ + __init__.py \ + Principal.py \ + Privilege.py + + + diff --git a/src/webdav/acp/Principal.py b/src/webdav/acp/Principal.py new file mode 100644 index 0000000..a0d5ec9 --- /dev/null +++ b/src/webdav/acp/Principal.py @@ -0,0 +1,189 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Handling of principals for ACEs according to WebDAV ACP specification. +""" + + +from webdav import Constants +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +class Principal(object): + """ + This class provides functionality for handling + principals according to the WebDAV ACP. + + @ivar displayname: Name of the principal for output + @type displayname: C{string} + @ivar principalURL: URL under which the principal can be referenced on the server. + @type principalURL: C{string} + @ivar property: Information on type of a pseudo/jproperty principal, e. g. + DAV:owner, DAV:authenticated, etc. + @type property: C{string} + + @cvar _TAG_LIST_PRINCIPALS: List of allowed XML tags within a principal declaration. + @type _TAG_LIST_PRINCIPALS: C{tuple} of C{string}s + @cvar _TAG_LIST_STATUS: List of XML tags for the status of a pseudo principal. + @type _TAG_LIST_STATUS: C{tuple} of C{string}s + """ + + # some local constants for this class to make things easier/more readable: + _TAG_LIST_PRINCIPALS = (Constants.TAG_HREF, # directly by URL + Constants.TAG_ALL, Constants.TAG_AUTHENTICATED, Constants.TAG_UNAUTHENTICATED, + # by log-in status + Constants.TAG_PROPERTY, # for property info, e. g. 'owner' + Constants.TAG_SELF, # only if the resource is the principal itself + Constants.TAG_PROP) # contains property info like 'displayname' + _TAG_LIST_STATUS = (Constants.TAG_ALL, Constants.TAG_AUTHENTICATED, Constants.TAG_UNAUTHENTICATED) + + # restrict instance variables + __slots__ = ('displayname', 'principalURL', 'property') + + def __init__(self, domroot=None, displayname=None, principalURL=None): + """ + Constructor should be called with either no parameters (create blank Principal), + one parameter (a DOM tree), or two parameters (displayname and URL or property tag). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + @param displayname: The display name of a principal (default: None). + @type displayname: C{string} + @param principalURL: The URL representing a principal (default: None). + @type principalURL: C{string} + + @raise WebdavError: When non-valid parameters or sets of parameters are + passed a L{WebdavError} is raised. + """ + self.displayname = None + self.principalURL = None + self.property = None + + if domroot: + for child in domroot.children: + if child.ns == Constants.NS_DAV and (child.name in self._TAG_LIST_PRINCIPALS): + if child.name == Constants.TAG_PROP: + self.displayname = \ + child.find(Constants.PROP_DISPLAY_NAME, Constants.NS_DAV) + elif child.name == Constants.TAG_HREF: + self.principalURL = child.textof() + if self.principalURL and self.property in self._TAG_LIST_STATUS: + raise WebdavError('Principal cannot contain a URL and "%s"' % (self.property)) + elif child.name == Constants.TAG_PROPERTY: + if child.count() == 1: + if self.property: + raise WebdavError('Property for principal has already been set: old "%s", new "%s"' \ + % (self.property, child.pop().name)) + elif self.principalURL: + raise WebdavError('Principal cannot contain a URL and "%s"' % (self.property)) + else: + self.property = child.pop().name + else: + raise WebdavError("There should be only one value in the property for a principal, we have: %s" \ + % child.name) + else: + if self.property: + raise WebdavError('Property for principal has already been set: old "%s", new "%s"' \ + % (self.property, child.name)) + else: + self.property = child.name + if self.principalURL and self.property in self._TAG_LIST_STATUS: + raise WebdavError('Principal cannot contain a URL and "%s"' % (self.property)) + else: # This shouldn't happen, something's wrong with the DOM tree + raise WebdavError('Non-valid tag in principal DOM tree for constructor: %s' % child.name) + elif displayname == None or principalURL == None: + if displayname: + self.displayname = displayname + if principalURL: + self.principalURL = principalURL + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('Non-valid parameters handed to Principal constructor.') + + def __cmp__(self, other): + if not isinstance(other, Principal): + return 1 + if self.displayname == other.displayname \ + and self.principalURL == other.principalURL \ + and self.property == other.property: + return 0 + else: + return 1 + + def __repr__(self): + return '' \ + % (self.displayname, self.principalURL, self.property) + + def copy(self, other): + """Copy Principal object. + + @param other: Another principal to copy. + @type other: L{Principal} object + + @raise WebdavError: When an object that is not a L{Principal} is passed + a L{WebdavError} is raised. + """ + if not isinstance(other, Principal): + raise WebdavError('Non-Principal object passed to copy method: ' % other.__class__) + self.displayname = other.displayname + self.principalURL = other.principalURL + self.property = other.property + + def isValid(self): + """ + Checks whether necessarry props for principal are set. + + @return: Validity of principal. + @rtype: C{bool} + """ + return (self.displayname and + (self.principalURL or self.property) and + not (self.principalURL and self.property)) + + def toXML(self, invert=False, displayname=False, defaultNameSpace=None): + """Returns string of Principal content in valid XML as described in WebDAV ACP. + + @param defaultNameSpace: Name space (default: None). + @type defaultNameSpace: C(string) + @param invert: True if principal should be inverted (default: False). + @type invert: C{bool} + @param displayname: True if displayname should be in output (default: False). + @type displayname: C{bool} + """ + # this check is needed for setting principals only: + # assert self.isValid(), "principal is not initialized or does not contain valid content!" + + PRINCIPAL = 'D:' + Constants.TAG_PRINCIPAL + res = '' + if self.principalURL: + res += '%s' % (Constants.TAG_HREF, self.principalURL, Constants.TAG_HREF) + elif self.property in self._TAG_LIST_STATUS \ + or self.property == Constants.TAG_SELF: + res += '' % (self.property) + elif self.property: + res += '' \ + % (Constants.TAG_PROPERTY, self.property, Constants.TAG_PROPERTY) + if self.displayname and displayname: + res += '%s' \ + % (Constants.TAG_PROP, Constants.PROP_DISPLAY_NAME, + self.displayname, + Constants.PROP_DISPLAY_NAME, Constants.TAG_PROP) + if invert: + res = '%s' % (res) + return '<%s>%s' % (PRINCIPAL, res, PRINCIPAL) diff --git a/src/webdav/acp/Privilege.py b/src/webdav/acp/Privilege.py new file mode 100644 index 0000000..abfdcf9 --- /dev/null +++ b/src/webdav/acp/Privilege.py @@ -0,0 +1,125 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +Handling for privileges for grant and deny clauses in ACEs +according to WebDAV ACP specification. +""" + + +from webdav import Constants +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +class Privilege(object): + """This class provides functionality for handling privileges for ACEs. + + @ivar name: Name of the privilege. + @type name: C{string} + + @cvar __privileges: List of allowed XML tags for privileges. + @type __privileges: C{tuple} of C{string}s + """ + + + __privileges = list() + + + def __init__(self, privilege=None, domroot=None): + """ + Constructor should be called with either no parameters (create blank Privilege), + one parameter (a DOM tree or privilege name to initialize it directly). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + @param privilege: The valid name of a privilege (default: None). + @type privilege: C{string} + + @raise WebdavError: When non-valid parameters or sets of parameters are + passed a L{WebdavError} is raised. + """ + + self.name = None + + if domroot: + if len(domroot.children) != 1: + raise WebdavError('Wrong number of elements for Privilege constructor, we have: %i' \ + % (len(domroot.children))) + else: + child = domroot.children[0] + if child.ns == Constants.NS_DAV and child.name in self.__privileges: + self.name = child.name + else: + raise WebdavError('Not a valid privilege tag, we have: %s%s' \ + % (child.ns, child.name)) + elif privilege: + if privilege in self.__privileges: + self.name = privilege + else: + raise WebdavError('Not a valid privilege tag, we have: %s.' % str(privilege)) + + @classmethod + def registerPrivileges(cls, privileges): + """ + Registers supported privilege tags. + + @param privileges: List of privilege tags. + @type privileges: C{list} of C{unicode} + """ + + for privilege in privileges: + cls.__privileges.append(privilege) + + def __cmp__(self, other): + """ Compares two Privilege instances. """ + if not isinstance(other, Privilege): + return 1 + if self.name != other.name: + return 1 + else: + return 0 + + def __repr__(self): + """ Returns the string representation of an instance. """ + return '' % (self.name) + + def copy(self, other): + """ + Copy Privilege object. + + @param other: Another privilege to copy. + @type other: L{Privilege} object + + @raise WebdavError: When an object that is not a L{Privilege} is passed + a L{WebdavError} is raised. + """ + if not isinstance(other, Privilege): + raise WebdavError('Non-Privilege object passed to copy method: %s' % other.__class__) + self.name = other.name + + def toXML(self): + """ + Returns privilege content as string in valid XML as described in WebDAV ACP. + + @param defaultNameSpace: Name space (default: None). + @type defaultNameSpace: C(string) + """ + assert self.name != None, "privilege is not initialized or does not contain valid content!" + + privilege = 'D:' + Constants.TAG_PRIVILEGE + return '<%s>' % (privilege, self.name, privilege) diff --git a/src/webdav/acp/__init__.py b/src/webdav/acp/__init__.py new file mode 100644 index 0000000..b5af299 --- /dev/null +++ b/src/webdav/acp/__init__.py @@ -0,0 +1,33 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from webdav import Constants +from webdav.acp.Acl import ACL +from webdav.acp.Ace import ACE +from webdav.acp.GrantDeny import GrantDeny +from webdav.acp.Privilege import Privilege +from webdav.acp.Principal import Principal + + +__version__ = "$LastChangedRevision$" + + +privileges = [Constants.TAG_READ, Constants.TAG_WRITE, Constants.TAG_WRITE_PROPERTIES, + Constants.TAG_WRITE_CONTENT, Constants.TAG_UNLOCK, Constants.TAG_READ_ACL, + Constants.TAG_READ_CURRENT_USER_PRIVILEGE_SET, Constants.TAG_WRITE_ACL, Constants.TAG_ALL, + Constants.TAG_BIND, Constants.TAG_UNBIND, Constants.TAG_TAMINO_SECURITY, + Constants.TAG_BIND_COLLECTION, Constants.TAG_UNBIND_COLLECTION, Constants.TAG_READ_PRIVATE_PROPERTIES, + Constants.TAG_WRITE_PRIVATE_PROPERTIES] +Privilege.registerPrivileges(privileges) diff --git a/src/webdav/davlib.py b/src/webdav/davlib.py new file mode 100644 index 0000000..a611e51 --- /dev/null +++ b/src/webdav/davlib.py @@ -0,0 +1,339 @@ +# pylint: disable-msg=W0402,W0231,W0141,R0903,C0321,W0701,R0904,C0103,W0201,W0102,R0913,W0622,E1101,C0111,C0121,R0901 +# DAV client library +# +# Copyright (C) 1998-2000 Guido van Rossum. All Rights Reserved. +# Written by Greg Stein. Given to Guido. Licensed using the Python license. +# +# This module is maintained by Greg and is available at: +# http://www.lyra.org/greg/python/davlib.py +# +# Since this isn't in the Python distribution yet, we'll use the CVS ID +# for tracking: +# $Id: davlib.py 3182 2008-02-22 15:57:55 +0000 (Fr, 22 Feb 2008) schlauch $ +# + +import httplib +import urllib +import string +import types +import mimetypes +import qp_xml + + +INFINITY = 'infinity' +XML_DOC_HEADER = '' +XML_CONTENT_TYPE = 'text/xml; charset="utf-8"' + +# block size for copying files up to the server +BLOCKSIZE = 16384 + + +class HTTPProtocolChooser(httplib.HTTPSConnection): + def __init__(self, *args, **kw): + self.protocol = kw.pop('protocol') + if self.__is_secure_protocol(): + self.default_port = 443 + else: + self.default_port = 80 + + apply(httplib.HTTPSConnection.__init__, (self,) + args, kw) + + def connect(self): + if self.__is_secure_protocol(): + httplib.HTTPSConnection.connect(self) + else: + httplib.HTTPConnection.connect(self) + + def __is_secure_protocol(self): + return (self.protocol == 'https') or (self.protocol == 'davs') + + +class HTTPConnectionAuth(HTTPProtocolChooser): + def __init__(self, *args, **kw): + apply(HTTPProtocolChooser.__init__, (self,) + args, kw) + + self.__username = None + self.__password = None + self.__nonce = None + self.__opaque = None + + def setauth(self, username, password): + self.__username = username + self.__password = password + + +def _parse_status(elem): + text = elem.textof() + idx1 = string.find(text, ' ') + idx2 = string.find(text, ' ', idx1+1) + return int(text[idx1:idx2]), text[idx2+1:] + +class _blank: + def __init__(self, **kw): + self.__dict__.update(kw) +class _propstat(_blank): pass +class _response(_blank): pass +class _multistatus(_blank): pass + +def _extract_propstat(elem): + ps = _propstat(prop={}, status=None, responsedescription=None) + for child in elem.children: + if child.ns != 'DAV:': + continue + if child.name == 'prop': + for prop in child.children: + ps.prop[(prop.ns, prop.name)] = prop + elif child.name == 'status': + ps.status = _parse_status(child) + elif child.name == 'responsedescription': + ps.responsedescription = child.textof() + ### unknown element name + + return ps + +def _extract_response(elem): + resp = _response(href=[], status=None, responsedescription=None, propstat=[]) + for child in elem.children: + if child.ns != 'DAV:': + continue + if child.name == 'href': + resp.href.append(child.textof()) + elif child.name == 'status': + resp.status = _parse_status(child) + elif child.name == 'responsedescription': + resp.responsedescription = child.textof() + elif child.name == 'propstat': + resp.propstat.append(_extract_propstat(child)) + ### unknown child element + + return resp + +def _extract_msr(root): + if root.ns != 'DAV:' or root.name != 'multistatus': + raise 'invalid response: expected' + + msr = _multistatus(responses=[ ], responsedescription=None) + + for child in root.children: + if child.ns != 'DAV:': + continue + if child.name == 'responsedescription': + msr.responsedescription = child.textof() + elif child.name == 'response': + msr.responses.append(_extract_response(child)) + ### unknown child element + + return msr + +def _extract_locktoken(root): + if root.ns != 'DAV:' or root.name != 'prop': + raise 'invalid response: expected' + elem = root.find('lockdiscovery', 'DAV:') + if not elem: + raise 'invalid response: expected' + elem = elem.find('activelock', 'DAV:') + if not elem: + raise 'invalid response: expected' + elem = elem.find('locktoken', 'DAV:') + if not elem: + raise 'invalid response: expected' + elem = elem.find('href', 'DAV:') + if not elem: + raise 'invalid response: expected' + return elem.textof() + + +class DAVResponse(httplib.HTTPResponse): + def parse_multistatus(self): + self.root = qp_xml.Parser().parse(self) + self.msr = _extract_msr(self.root) + + def parse_lock_response(self): + self.root = qp_xml.Parser().parse(self) + self.locktoken = _extract_locktoken(self.root) + + +class DAV(HTTPConnectionAuth): + + response_class = DAVResponse + + def get(self, url, extra_hdrs={ }): + return self._request('GET', url, extra_hdrs=extra_hdrs) + + def head(self, url, extra_hdrs={ }): + return self._request('HEAD', url, extra_hdrs=extra_hdrs) + + def post(self, url, data={ }, body=None, extra_hdrs={ }): + headers = extra_hdrs.copy() + + assert body or data, "body or data must be supplied" + assert not (body and data), "cannot supply both body and data" + if data: + body = '' + for key, value in data.items(): + if isinstance(value, types.ListType): + for item in value: + body = body + '&' + key + '=' + urllib.quote(str(item)) + else: + body = body + '&' + key + '=' + urllib.quote(str(value)) + body = body[1:] + headers['Content-Type'] = 'application/x-www-form-urlencoded' + + return self._request('POST', url, body, headers) + + def options(self, url='*', extra_hdrs={ }): + return self._request('OPTIONS', url, extra_hdrs=extra_hdrs) + + def trace(self, url, extra_hdrs={ }): + return self._request('TRACE', url, extra_hdrs=extra_hdrs) + + def put(self, url, contents, + content_type=None, content_enc=None, extra_hdrs={ }): + + if not content_type: + content_type, content_enc = mimetypes.guess_type(url) + + headers = extra_hdrs.copy() + if content_type: + headers['Content-Type'] = content_type + if content_enc: + headers['Content-Encoding'] = content_enc + return self._request('PUT', url, contents, headers) + + def delete(self, url, extra_hdrs={ }): + return self._request('DELETE', url, extra_hdrs=extra_hdrs) + + def propfind(self, url, body=None, depth=None, extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Content-Type'] = XML_CONTENT_TYPE + if depth is not None: + headers['Depth'] = str(depth) + return self._request('PROPFIND', url, body, headers) + + def proppatch(self, url, body, extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Content-Type'] = XML_CONTENT_TYPE + return self._request('PROPPATCH', url, body, headers) + + def mkcol(self, url, extra_hdrs={ }): + return self._request('MKCOL', url, extra_hdrs=extra_hdrs) + + def move(self, src, dst, extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Destination'] = dst + return self._request('MOVE', src, extra_hdrs=headers) + + def copy(self, src, dst, depth=None, extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Destination'] = dst + if depth is not None: + headers['Depth'] = str(depth) + return self._request('COPY', src, extra_hdrs=headers) + + def lock(self, url, owner='', timeout=None, depth=None, + scope='exclusive', type='write', extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Content-Type'] = XML_CONTENT_TYPE + if depth is not None: + headers['Depth'] = str(depth) + if timeout is not None: + headers['Timeout'] = timeout + body = XML_DOC_HEADER + \ + '' + \ + '' % scope + \ + '' % type + \ + '' + owner + '' + \ + '' + return self._request('LOCK', url, body, extra_hdrs=headers) + + def unlock(self, url, locktoken, extra_hdrs={ }): + headers = extra_hdrs.copy() + if locktoken[0] != '<': + locktoken = '<' + locktoken + '>' + headers['Lock-Token'] = locktoken + return self._request('UNLOCK', url, extra_hdrs=headers) + + def _request(self, method, url, body=None, extra_hdrs={}): + "Internal method for sending a request." + + self.request(method, url, body, extra_hdrs) + return self.getresponse() + + + # + # Higher-level methods for typical client use + # + + def allprops(self, url, depth=None): + body = XML_DOC_HEADER + \ + '' + return self.propfind(url, body, depth=depth) + + def propnames(self, url, depth=None): + body = XML_DOC_HEADER + \ + '' + return self.propfind(url, body, depth) + + def getprops(self, url, *names, **kw): + assert names, 'at least one property name must be provided' + if kw.has_key('ns'): + xmlns = ' xmlns:NS="' + kw['ns'] + '"' + ns = 'NS:' + del kw['ns'] + else: + xmlns = ns = '' + if kw.has_key('depth'): + depth = kw['depth'] + del kw['depth'] + else: + depth = 0 + assert not kw, 'unknown arguments' + body = XML_DOC_HEADER + \ + '<' + ns + \ + string.joinfields(names, '/><' + ns) + \ + '/>' + return self.propfind(url, body, depth) + + def delprops(self, url, *names, **kw): + assert names, 'at least one property name must be provided' + if kw.has_key('ns'): + xmlns = ' xmlns:NS="' + kw['ns'] + '"' + ns = 'NS:' + del kw['ns'] + else: + xmlns = ns = '' + assert not kw, 'unknown arguments' + body = XML_DOC_HEADER + \ + '<' + ns + \ + string.joinfields(names, '/><' + ns) + \ + '/>' + return self.proppatch(url, body) + + def setprops(self, url, *xmlprops, **props): + assert xmlprops or props, 'at least one property must be provided' + xmlprops = list(xmlprops) + if props.has_key('ns'): + xmlns = ' xmlns:NS="' + props['ns'] + '"' + ns = 'NS:' + del props['ns'] + else: + xmlns = ns = '' + for key, value in props.items(): + if value: + xmlprops.append('<%s%s>%s' % (ns, key, value, ns, key)) + else: + xmlprops.append('<%s%s/>' % (ns, key)) + elems = string.joinfields(xmlprops, '') + body = XML_DOC_HEADER + \ + '' + \ + elems + \ + '' + return self.proppatch(url, body) + + def get_lock(self, url, owner='', timeout=None, depth=None): + response = self.lock(url, owner, timeout, depth) + response.parse_lock_response() + return response.locktoken + diff --git a/src/webdav/logger.py b/src/webdav/logger.py new file mode 100644 index 0000000..d2538ef --- /dev/null +++ b/src/webdav/logger.py @@ -0,0 +1,51 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""" +Module provides access to a configured logger instance. +The logger writes C{sys.stdout}. +""" + + +import logging +import sys + + +__version__ = "$LastChangedRevision$"[11:-2] + + +_defaultLoggerName = "webdavLogger" +_fileLogFormat = "%(asctime)s: %(levelname)s: %(message)s" + + +def getDefaultLogger(handler=None): + """ + Returns a configured logger object. + + @return: Logger instance. + @rtype: C{logging.Logger} + """ + + myLogger = logging.getLogger(_defaultLoggerName) + if len(myLogger.handlers) == 0: + myLogger.level = logging.DEBUG + formatter = logging.Formatter(_fileLogFormat) + if handler is None: + stdoutHandler = logging.StreamHandler(sys.stdout) + stdoutHandler.setFormatter(formatter) + myLogger.addHandler(stdoutHandler) + else: + myLogger.addHandler(handler) + return myLogger diff --git a/src/webdav/qp_xml.py b/src/webdav/qp_xml.py new file mode 100644 index 0000000..f167e1b --- /dev/null +++ b/src/webdav/qp_xml.py @@ -0,0 +1,240 @@ +# pylint: disable-msg=W0311,E1101,E1103,W0201,C0103,W0622,W0402,W0706,R0911,W0613,W0612,R0912,W0141,C0111,C0121 + +# qp_xml: Quick Parsing for XML +# +# Written by Greg Stein. Public Domain. +# No Copyright, no Rights Reserved, and no Warranties. +# +# This module is maintained by Greg and is available as part of the XML-SIG +# distribution. This module and its changelog can be fetched at: +# http://www.lyra.org/cgi-bin/viewcvs.cgi/xml/xml/utils/qp_xml.py +# +# Additional information can be found on Greg's Python page at: +# http://www.lyra.org/greg/python/ +# +# This module was added to the XML-SIG distribution on February 14, 2000. +# As part of that distribution, it falls under the XML distribution license. +# + +import string +from xml.parsers import expat + + +error = __name__ + '.error' + + +# +# The parsing class. Instantiate and pass a string/file to .parse() +# +class Parser: + def __init__(self): + self.reset() + + def reset(self): + self.root = None + self.cur_elem = None + + def find_prefix(self, prefix): + elem = self.cur_elem + while elem: + if elem.ns_scope.has_key(prefix): + return elem.ns_scope[prefix] + elem = elem.parent + + if prefix == '': + return '' # empty URL for "no namespace" + + return None + + def process_prefix(self, name, use_default): + idx = string.find(name, ':') + if idx == -1: + if use_default: + return self.find_prefix(''), name + return '', name # no namespace + + if string.lower(name[:3]) == 'xml': + return '', name # name is reserved by XML. don't break out a NS. + + ns = self.find_prefix(name[:idx]) + if ns is None: + raise error, 'namespace prefix ("%s") not found' % name[:idx] + + return ns, name[idx+1:] + + def start(self, name, attrs): + elem = _element(name=name, lang=None, parent=None, + children=[], ns_scope={}, attrs={}, + first_cdata='', following_cdata='') + + if self.cur_elem: + elem.parent = self.cur_elem + elem.parent.children.append(elem) + self.cur_elem = elem + else: + self.cur_elem = self.root = elem + + work_attrs = [ ] + + # scan for namespace declarations (and xml:lang while we're at it) + for name, value in attrs.items(): + if name == 'xmlns': + elem.ns_scope[''] = value + elif name[:6] == 'xmlns:': + elem.ns_scope[name[6:]] = value + elif name == 'xml:lang': + elem.lang = value + else: + work_attrs.append((name, value)) + + # inherit xml:lang from parent + if elem.lang is None and elem.parent: + elem.lang = elem.parent.lang + + # process prefix of the element name + elem.ns, elem.name = self.process_prefix(elem.name, 1) + + # process attributes' namespace prefixes + for name, value in work_attrs: + elem.attrs[self.process_prefix(name, 0)] = value + + def end(self, name): + parent = self.cur_elem.parent + + del self.cur_elem.ns_scope + del self.cur_elem.parent + + self.cur_elem = parent + + def cdata(self, data): + elem = self.cur_elem + if elem.children: + last = elem.children[-1] + last.following_cdata = last.following_cdata + data + else: + elem.first_cdata = elem.first_cdata + data + + def parse(self, input): + self.reset() + + p = expat.ParserCreate() + p.StartElementHandler = self.start + p.EndElementHandler = self.end + p.CharacterDataHandler = self.cdata + + try: + if type(input) == type(''): + p.Parse(input, 1) + else: + while 1: + s = input.read(_BLOCKSIZE) + if not s: + p.Parse('', 1) + break + + p.Parse(s, 0) + + finally: + if self.root: + _clean_tree(self.root) + + return self.root + + +# +# handy function for dumping a tree that is returned by Parser +# +def dump(f, root): + f.write('\n') + namespaces = _collect_ns(root) + _dump_recurse(f, root, namespaces, dump_ns=1) + f.write('\n') + + +# +# This function returns the element's CDATA. Note: this is not recursive -- +# it only returns the CDATA immediately within the element, excluding the +# CDATA in child elements. +# +def textof(elem): + return elem.textof() + + +######################################################################### +# +# private stuff for qp_xml +# + +_BLOCKSIZE = 16384 # chunk size for parsing input + +class _element: + def __init__(self, **kw): + self.__dict__.update(kw) + + def textof(self): + '''Return the CDATA of this element. + + Note: this is not recursive -- it only returns the CDATA immediately + within the element, excluding the CDATA in child elements. + ''' + s = self.first_cdata + for child in self.children: + s = s + child.following_cdata + return s + + def find(self, name, ns=''): + for elem in self.children: + if elem.name == name and elem.ns == ns: + return elem + return None + + +def _clean_tree(elem): + elem.parent = None + del elem.parent + map(_clean_tree, elem.children) + + +def _collect_recurse(elem, dict): + dict[elem.ns] = None + for ns, name in elem.attrs.keys(): + dict[ns] = None + for child in elem.children: + _collect_recurse(child, dict) + +def _collect_ns(elem): + "Collect all namespaces into a NAMESPACE -> PREFIX mapping." + d = { '' : None } + _collect_recurse(elem, d) + del d[''] # make sure we don't pick up no-namespace entries + keys = d.keys() + for i in range(len(keys)): + d[keys[i]] = i + return d + +def _dump_recurse(f, elem, namespaces, lang=None, dump_ns=0): + if elem.ns: + f.write('' + elem.first_cdata) + for child in elem.children: + _dump_recurse(f, child, namespaces, elem.lang) + f.write(child.following_cdata) + if elem.ns: + f.write('' % (namespaces[elem.ns], elem.name)) + else: + f.write('' % elem.name) + else: + f.write('/>') diff --git a/src/webdav/uuid_.py b/src/webdav/uuid_.py new file mode 100644 index 0000000..3b590e8 --- /dev/null +++ b/src/webdav/uuid_.py @@ -0,0 +1,476 @@ +r"""UUID objects (universally unique identifiers) according to RFC 4122. + +This module provides immutable UUID objects (class UUID) and the functions +uuid1(), uuid3(), uuid4(), uuid5() for generating version 1, 3, 4, and 5 +UUIDs as specified in RFC 4122. + +If all you want is a unique ID, you should probably call uuid1() or uuid4(). +Note that uuid1() may compromise privacy since it creates a UUID containing +the computer's network address. uuid4() creates a random UUID. + +Typical usage: + + >>> import uuid + + # make a UUID based on the host ID and current time + >>> uuid.uuid1() + UUID('a8098c1a-f86e-11da-bd1a-00112444be1e') + + # make a UUID using an MD5 hash of a namespace UUID and a name + >>> uuid.uuid3(uuid.NAMESPACE_DNS, 'python.org') + UUID('6fa459ea-ee8a-3ca4-894e-db77e160355e') + + # make a random UUID + >>> uuid.uuid4() + UUID('16fd2706-8baf-433b-82eb-8c7fada847da') + + # make a UUID using a SHA-1 hash of a namespace UUID and a name + >>> uuid.uuid5(uuid.NAMESPACE_DNS, 'python.org') + UUID('886313e1-3b8a-5372-9b90-0c9aee199e5d') + + # make a UUID from a string of hex digits (braces and hyphens ignored) + >>> x = uuid.UUID('{00010203-0405-0607-0809-0a0b0c0d0e0f}') + + # convert a UUID to a string of hex digits in standard form + >>> str(x) + '00010203-0405-0607-0809-0a0b0c0d0e0f' + + # get the raw 16 bytes of the UUID + >>> x.bytes + '\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f' + + # make a UUID from a 16-byte string + >>> uuid.UUID(bytes=x.bytes) + UUID('00010203-0405-0607-0809-0a0b0c0d0e0f') +""" + +__author__ = 'Ka-Ping Yee ' +__date__ = '$Date: 2006/06/12 23:15:40 $'.split()[1].replace('/', '-') +__version__ = '$Revision: 1.30 $'.split()[1] + +RESERVED_NCS, RFC_4122, RESERVED_MICROSOFT, RESERVED_FUTURE = [ + 'reserved for NCS compatibility', 'specified in RFC 4122', + 'reserved for Microsoft compatibility', 'reserved for future definition'] + +class UUID(object): + """Instances of the UUID class represent UUIDs as specified in RFC 4122. + UUID objects are immutable, hashable, and usable as dictionary keys. + Converting a UUID to a string with str() yields something in the form + '12345678-1234-1234-1234-123456789abc'. The UUID constructor accepts + four possible forms: a similar string of hexadecimal digits, or a + string of 16 raw bytes as an argument named 'bytes', or a tuple of + six integer fields (with 32-bit, 16-bit, 16-bit, 8-bit, 8-bit, and + 48-bit values respectively) as an argument named 'fields', or a single + 128-bit integer as an argument named 'int'. + + UUIDs have these read-only attributes: + + bytes the UUID as a 16-byte string + + fields a tuple of the six integer fields of the UUID, + which are also available as six individual attributes + and two derived attributes: + + time_low the first 32 bits of the UUID + time_mid the next 16 bits of the UUID + time_hi_version the next 16 bits of the UUID + clock_seq_hi_variant the next 8 bits of the UUID + clock_seq_low the next 8 bits of the UUID + node the last 48 bits of the UUID + + time the 60-bit timestamp + clock_seq the 14-bit sequence number + + hex the UUID as a 32-character hexadecimal string + + int the UUID as a 128-bit integer + + urn the UUID as a URN as specified in RFC 4122 + + variant the UUID variant (one of the constants RESERVED_NCS, + RFC_4122, RESERVED_MICROSOFT, or RESERVED_FUTURE) + + version the UUID version number (1 through 5, meaningful only + when the variant is RFC_4122) + """ + + def __init__(self, hex=None, bytes=None, fields=None, int=None, + version=None): + r"""Create a UUID from either a string of 32 hexadecimal digits, + a string of 16 bytes as the 'bytes' argument, a tuple of six + integers (32-bit time_low, 16-bit time_mid, 16-bit time_hi_version, + 8-bit clock_seq_hi_variant, 8-bit clock_seq_low, 48-bit node) as + the 'fields' argument, or a single 128-bit integer as the 'int' + argument. When a string of hex digits is given, curly braces, + hyphens, and a URN prefix are all optional. For example, these + expressions all yield the same UUID: + + UUID('{12345678-1234-5678-1234-567812345678}') + UUID('12345678123456781234567812345678') + UUID('urn:uuid:12345678-1234-5678-1234-567812345678') + UUID(bytes='\x12\x34\x56\x78'*4) + UUID(fields=(0x12345678, 0x1234, 0x5678, 0x12, 0x34, 0x567812345678)) + UUID(int=0x12345678123456781234567812345678) + + Exactly one of 'hex', 'bytes', 'fields', or 'int' must be given. + The 'version' argument is optional; if given, the resulting UUID + will have its variant and version number set according to RFC 4122, + overriding bits in the given 'hex', 'bytes', 'fields', or 'int'. + """ + + if [hex, bytes, fields, int].count(None) != 3: + raise TypeError('need just one of hex, bytes, fields, or int') + if hex is not None: + hex = hex.replace('urn:', '').replace('uuid:', '') + hex = hex.strip('{}').replace('-', '') + if len(hex) != 32: + raise ValueError('badly formed hexadecimal UUID string') + int = long(hex, 16) + if bytes is not None: + if len(bytes) != 16: + raise ValueError('bytes is not a 16-char string') + int = long(('%02x'*16) % tuple(map(ord, bytes)), 16) + if fields is not None: + if len(fields) != 6: + raise ValueError('fields is not a 6-tuple') + (time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node) = fields + if not 0 <= time_low < 1<<32L: + raise ValueError('field 1 out of range (need a 32-bit value)') + if not 0 <= time_mid < 1<<16L: + raise ValueError('field 2 out of range (need a 16-bit value)') + if not 0 <= time_hi_version < 1<<16L: + raise ValueError('field 3 out of range (need a 16-bit value)') + if not 0 <= clock_seq_hi_variant < 1<<8L: + raise ValueError('field 4 out of range (need an 8-bit value)') + if not 0 <= clock_seq_low < 1<<8L: + raise ValueError('field 5 out of range (need an 8-bit value)') + if not 0 <= node < 1<<48L: + raise ValueError('field 6 out of range (need a 48-bit value)') + clock_seq = (clock_seq_hi_variant << 8L) | clock_seq_low + int = ((time_low << 96L) | (time_mid << 80L) | + (time_hi_version << 64L) | (clock_seq << 48L) | node) + if int is not None: + if not 0 <= int < 1<<128L: + raise ValueError('int is out of range (need a 128-bit value)') + if version is not None: + if not 1 <= version <= 5: + raise ValueError('illegal version number') + # Set the variant to RFC 4122. + int &= ~(0xc000 << 48L) + int |= 0x8000 << 48L + # Set the version number. + int &= ~(0xf000 << 64L) + int |= version << 76L + self.__dict__['int'] = int + + def __cmp__(self, other): + if isinstance(other, UUID): + return cmp(self.int, other.int) + return NotImplemented + + def __hash__(self): + return hash(self.int) + + def __int__(self): + return self.int + + def __repr__(self): + return 'UUID(%r)' % str(self) + + def __setattr__(self, name, value): + raise TypeError('UUID objects are immutable') + + def __str__(self): + hex = '%032x' % self.int + return '%s-%s-%s-%s-%s' % ( + hex[:8], hex[8:12], hex[12:16], hex[16:20], hex[20:]) + + def get_bytes(self): + bytes = '' + for shift in range(0, 128, 8): + bytes = chr((self.int >> shift) & 0xff) + bytes + return bytes + + bytes = property(get_bytes) + + def get_fields(self): + return (self.time_low, self.time_mid, self.time_hi_version, + self.clock_seq_hi_variant, self.clock_seq_low, self.node) + + fields = property(get_fields) + + def get_time_low(self): + return self.int >> 96L + + time_low = property(get_time_low) + + def get_time_mid(self): + return (self.int >> 80L) & 0xffff + + time_mid = property(get_time_mid) + + def get_time_hi_version(self): + return (self.int >> 64L) & 0xffff + + time_hi_version = property(get_time_hi_version) + + def get_clock_seq_hi_variant(self): + return (self.int >> 56L) & 0xff + + clock_seq_hi_variant = property(get_clock_seq_hi_variant) + + def get_clock_seq_low(self): + return (self.int >> 48L) & 0xff + + clock_seq_low = property(get_clock_seq_low) + + def get_time(self): + return (((self.time_hi_version & 0x0fffL) << 48L) | + (self.time_mid << 32L) | self.time_low) + + time = property(get_time) + + def get_clock_seq(self): + return (((self.clock_seq_hi_variant & 0x3fL) << 8L) | + self.clock_seq_low) + + clock_seq = property(get_clock_seq) + + def get_node(self): + return self.int & 0xffffffffffff + + node = property(get_node) + + def get_hex(self): + return '%032x' % self.int + + hex = property(get_hex) + + def get_urn(self): + return 'urn:uuid:' + str(self) + + urn = property(get_urn) + + def get_variant(self): + if not self.int & (0x8000 << 48L): + return RESERVED_NCS + elif not self.int & (0x4000 << 48L): + return RFC_4122 + elif not self.int & (0x2000 << 48L): + return RESERVED_MICROSOFT + else: + return RESERVED_FUTURE + + variant = property(get_variant) + + def get_version(self): + # The version bits are only meaningful for RFC 4122 UUIDs. + if self.variant == RFC_4122: + return int((self.int >> 76L) & 0xf) + + version = property(get_version) + +def _ifconfig_getnode(): + """Get the hardware address on Unix by running ifconfig.""" + import os + for dir in ['', '/sbin/', '/usr/sbin']: + try: + pipe = os.popen(os.path.join(dir, 'ifconfig')) + except IOError: + continue + for line in pipe: + words = line.lower().split() + for i in range(len(words)): + if words[i] in ['hwaddr', 'ether']: + return int(words[i + 1].replace(':', ''), 16) + +def _ipconfig_getnode(): + """Get the hardware address on Windows by running ipconfig.exe.""" + import os, re + dirs = ['', r'c:\windows\system32', r'c:\winnt\system32'] + try: + import ctypes + buffer = ctypes.create_string_buffer(300) + ctypes.windll.kernel32.GetSystemDirectoryA(buffer, 300) + dirs.insert(0, buffer.value.decode('mbcs')) + except: + pass + for dir in dirs: + try: + pipe = os.popen(os.path.join(dir, 'ipconfig') + ' /all') + except IOError: + continue + for line in pipe: + value = line.split(':')[-1].strip().lower() + if re.match('([0-9a-f][0-9a-f]-){5}[0-9a-f][0-9a-f]', value): + return int(value.replace('-', ''), 16) + +def _netbios_getnode(): + """Get the hardware address on Windows using NetBIOS calls. + See http://support.microsoft.com/kb/118623 for details.""" + import win32wnet, netbios + ncb = netbios.NCB() + ncb.Command = netbios.NCBENUM + ncb.Buffer = adapters = netbios.LANA_ENUM() + adapters._pack() + if win32wnet.Netbios(ncb) != 0: + return + adapters._unpack() + for i in range(adapters.length): + ncb.Reset() + ncb.Command = netbios.NCBRESET + ncb.Lana_num = ord(adapters.lana[i]) + if win32wnet.Netbios(ncb) != 0: + continue + ncb.Reset() + ncb.Command = netbios.NCBASTAT + ncb.Lana_num = ord(adapters.lana[i]) + ncb.Callname = '*'.ljust(16) + ncb.Buffer = status = netbios.ADAPTER_STATUS() + if win32wnet.Netbios(ncb) != 0: + continue + status._unpack() + bytes = map(ord, status.adapter_address) + return ((bytes[0]<<40L) + (bytes[1]<<32L) + (bytes[2]<<24L) + + (bytes[3]<<16L) + (bytes[4]<<8L) + bytes[5]) + +# Thanks to Thomas Heller for ctypes and for his help with its use here. + +# If ctypes is available, use it to find system routines for UUID generation. +_uuid_generate_random = _uuid_generate_time = _UuidCreate = None +try: + import ctypes, ctypes.util + _buffer = ctypes.create_string_buffer(16) + + # The uuid_generate_* routines are provided by libuuid on at least + # Linux and FreeBSD, and provided by libc on Mac OS X. + for libname in ['uuid', 'c']: + try: + lib = ctypes.CDLL(ctypes.util.find_library(libname)) + except: + continue + if hasattr(lib, 'uuid_generate_random'): + _uuid_generate_random = lib.uuid_generate_random + if hasattr(lib, 'uuid_generate_time'): + _uuid_generate_time = lib.uuid_generate_time + + # On Windows prior to 2000, UuidCreate gives a UUID containing the + # hardware address. On Windows 2000 and later, UuidCreate makes a + # random UUID and UuidCreateSequential gives a UUID containing the + # hardware address. These routines are provided by the RPC runtime. + try: + lib = ctypes.windll.rpcrt4 + except: + lib = None + _UuidCreate = getattr(lib, 'UuidCreateSequential', + getattr(lib, 'UuidCreate', None)) +except: + pass + +def _unixdll_getnode(): + """Get the hardware address on Unix using ctypes.""" + _uuid_generate_time(_buffer) + return UUID(bytes=_buffer.raw).node + +def _windll_getnode(): + """Get the hardware address on Windows using ctypes.""" + if _UuidCreate(_buffer) == 0: + return UUID(bytes=_buffer.raw).node + +def _random_getnode(): + """Get a random node ID, with eighth bit set as suggested by RFC 4122.""" + import random + return random.randrange(0, 1<<48L) | 0x010000000000L + +_node = None + +def getnode(): + """Get the hardware address as a 48-bit integer. The first time this + runs, it may launch a separate program, which could be quite slow. If + all attempts to obtain the hardware address fail, we choose a random + 48-bit number with its eighth bit set to 1 as recommended in RFC 4122.""" + + global _node + if _node is not None: + return _node + + import sys + if sys.platform == 'win32': + getters = [_windll_getnode, _netbios_getnode, _ipconfig_getnode] + else: + getters = [_unixdll_getnode, _ifconfig_getnode] + + for getter in getters + [_random_getnode]: + try: + _node = getter() + except: + continue + if _node is not None: + return _node + +def uuid1(node=None, clock_seq=None): + """Generate a UUID from a host ID, sequence number, and the current time. + If 'node' is not given, getnode() is used to obtain the hardware + address. If 'clock_seq' is given, it is used as the sequence number; + otherwise a random 14-bit sequence number is chosen.""" + + # When the system provides a version-1 UUID generator, use it (but don't + # use UuidCreate here because its UUIDs don't conform to RFC 4122). + if _uuid_generate_time and node is clock_seq is None: + _uuid_generate_time(_buffer) + return UUID(bytes=_buffer.raw) + + import time + nanoseconds = int(time.time() * 1e9) + # 0x01b21dd213814000 is the number of 100-ns intervals between the + # UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00. + timestamp = int(nanoseconds/100) + 0x01b21dd213814000L + if clock_seq is None: + import random + clock_seq = random.randrange(1<<14L) # instead of stable storage + time_low = timestamp & 0xffffffffL + time_mid = (timestamp >> 32L) & 0xffffL + time_hi_version = (timestamp >> 48L) & 0x0fffL + clock_seq_low = clock_seq & 0xffL + clock_seq_hi_variant = (clock_seq >> 8L) & 0x3fL + if node is None: + node = getnode() + return UUID(fields=(time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node), version=1) + +def uuid3(namespace, name): + """Generate a UUID from the MD5 hash of a namespace UUID and a name.""" + import md5 + hash = md5.md5(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=3) + +def uuid4(): + """Generate a random UUID.""" + + # When the system provides a version-4 UUID generator, use it. + if _uuid_generate_random: + _uuid_generate_random(_buffer) + return UUID(bytes=_buffer.raw) + + # Otherwise, get randomness from urandom or the 'random' module. + try: + import os + return UUID(bytes=os.urandom(16), version=4) + except: + import random + bytes = [chr(random.randrange(256)) for i in range(16)] + return UUID(bytes=bytes, version=4) + +def uuid5(namespace, name): + """Generate a UUID from the SHA-1 hash of a namespace UUID and a name.""" + import sha + hash = sha.sha(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=5) + +# The following standard UUIDs are for use with uuid3() or uuid5(). + +NAMESPACE_DNS = UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_URL = UUID('6ba7b811-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_OID = UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_X500 = UUID('6ba7b814-9dad-11d1-80b4-00c04fd430c8') -- cgit v0.9.1