diff --git a/evennia/accounts/__init__.py b/evennia/accounts/__init__.py new file mode 100644 index 0000000000..4ba99b4568 --- /dev/null +++ b/evennia/accounts/__init__.py @@ -0,0 +1,6 @@ +""" +This sub-package defines the out-of-character entities known as +Accounts. These are equivalent to 'accounts' and can puppet one or +more Objects depending on settings. An Account has no in-game existence. + +""" diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py new file mode 100644 index 0000000000..865aad57ba --- /dev/null +++ b/evennia/accounts/accounts.py @@ -0,0 +1,971 @@ +""" +Typeclass for Account objects + +Note that this object is primarily intended to +store OOC information, not game info! This +object represents the actual user (not their +character) and has NO actual precence in the +game world (this is handled by the associated +character object, so you should customize that +instead for most things). + +""" + +import time +from django.conf import settings +from django.utils import timezone +from evennia.typeclasses.models import TypeclassBase +from evennia.accounts.manager import AccountManager +from evennia.accounts.models import AccountDB +from evennia.objects.models import ObjectDB +from evennia.comms.models import ChannelDB +from evennia.commands import cmdhandler +from evennia.utils import logger +from evennia.utils.utils import (lazy_property, + make_iter, to_unicode, is_iter, + variable_from_module) +from evennia.typeclasses.attributes import NickHandler +from evennia.scripts.scripthandler import ScriptHandler +from evennia.commands.cmdsethandler import CmdSetHandler + +from django.utils.translation import ugettext as _ +from future.utils import with_metaclass + +__all__ = ("DefaultAccount",) + +_SESSIONS = None + +_AT_SEARCH_RESULT = variable_from_module(*settings.SEARCH_AT_RESULT.rsplit('.', 1)) +_MULTISESSION_MODE = settings.MULTISESSION_MODE +_MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS +_CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT +_CONNECT_CHANNEL = None + + +class AccountSessionHandler(object): + """ + Manages the session(s) attached to an account. + + """ + + def __init__(self, account): + """ + Initializes the handler. + + Args: + account (Account): The Account on which this handler is defined. + + """ + self.account = account + + def get(self, sessid=None): + """ + Get the sessions linked to this object. + + Args: + sessid (int, optional): Specify a given session by + session id. + + Returns: + sessions (list): A list of Session objects. If `sessid` + is given, this is a list with one (or zero) elements. + + """ + global _SESSIONS + if not _SESSIONS: + from evennia.server.sessionhandler import SESSIONS as _SESSIONS + if sessid: + return make_iter(_SESSIONS.session_from_account(self.account, sessid)) + else: + return _SESSIONS.sessions_from_account(self.account) + + def all(self): + """ + Alias to get(), returning all sessions. + + Returns: + sessions (list): All sessions. + + """ + return self.get() + + def count(self): + """ + Get amount of sessions connected. + + Returns: + sesslen (int): Number of sessions handled. + + """ + return len(self.get()) + + +class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): + """ + This is the base Typeclass for all Accounts. Accounts represent + the person playing the game and tracks account info, password + etc. They are OOC entities without presence in-game. An Account + can connect to a Character Object in order to "enter" the + game. + + Account Typeclass API: + + * Available properties (only available on initiated typeclass objects) + + - key (string) - name of account + - name (string)- wrapper for user.username + - aliases (list of strings) - aliases to the object. Will be saved to + database as AliasDB entries but returned as strings. + - dbref (int, read-only) - unique #id-number. Also "id" can be used. + - date_created (string) - time stamp of object creation + - permissions (list of strings) - list of permission strings + - user (User, read-only) - django User authorization object + - obj (Object) - game object controlled by account. 'character' can also + be used. + - sessions (list of Sessions) - sessions connected to this account + - is_superuser (bool, read-only) - if the connected user is a superuser + + * Handlers + + - locks - lock-handler: use locks.add() to add new lock strings + - db - attribute-handler: store/retrieve database attributes on this + self.db.myattr=val, val=self.db.myattr + - ndb - non-persistent attribute handler: same as db but does not + create a database entry when storing data + - scripts - script-handler. Add new scripts to object with scripts.add() + - cmdset - cmdset-handler. Use cmdset.add() to add new cmdsets to object + - nicks - nick-handler. New nicks with nicks.add(). + + * Helper methods + + - msg(text=None, from_obj=None, session=None, options=None, **kwargs) + - execute_cmd(raw_string) + - search(ostring, global_search=False, attribute_name=None, + use_nicks=False, location=None, + ignore_errors=False, account=False) + - is_typeclass(typeclass, exact=False) + - swap_typeclass(new_typeclass, clean_attributes=False, no_default=True) + - access(accessing_obj, access_type='read', default=False, no_superuser_bypass=False) + - check_permstring(permstring) + + * Hook methods + + basetype_setup() + at_account_creation() + + > note that the following hooks are also found on Objects and are + usually handled on the character level: + + - at_init() + - at_access() + - at_cmdset_get(**kwargs) + - at_first_login() + - at_post_login(session=None) + - at_disconnect() + - at_message_receive() + - at_message_send() + - at_server_reload() + - at_server_shutdown() + + """ + + objects = AccountManager() + + # properties + @lazy_property + def cmdset(self): + return CmdSetHandler(self, True) + + @lazy_property + def scripts(self): + return ScriptHandler(self) + + @lazy_property + def nicks(self): + return NickHandler(self) + + @lazy_property + def sessions(self): + return AccountSessionHandler(self) + + # session-related methods + + def disconnect_session_from_account(self, session): + """ + Access method for disconnecting a given session from the + account (connection happens automatically in the + sessionhandler) + + Args: + session (Session): Session to disconnect. + + """ + global _SESSIONS + if not _SESSIONS: + from evennia.server.sessionhandler import SESSIONS as _SESSIONS + _SESSIONS.disconnect(session) + + # puppeting operations + + def puppet_object(self, session, obj): + """ + Use the given session to control (puppet) the given object (usually + a Character type). + + Args: + session (Session): session to use for puppeting + obj (Object): the object to start puppeting + + Raises: + RuntimeError: If puppeting is not possible, the + `exception.msg` will contain the reason. + + + """ + # safety checks + if not obj: + raise RuntimeError("Object not found") + if not session: + raise RuntimeError("Session not found") + if self.get_puppet(session) == obj: + # already puppeting this object + self.msg("You are already puppeting this object.") + return + if not obj.access(self, 'puppet'): + # no access + self.msg("You don't have permission to puppet '%s'." % obj.key) + return + if obj.account: + # object already puppeted + if obj.account == self: + if obj.sessions.count(): + # we may take over another of our sessions + # output messages to the affected sessions + if _MULTISESSION_MODE in (1, 3): + txt1 = "Sharing |c%s|n with another of your sessions." + txt2 = "|c%s|n|G is now shared from another of your sessions.|n" + self.msg(txt1 % obj.name, session=session) + self.msg(txt2 % obj.name, session=obj.sessions.all()) + else: + txt1 = "Taking over |c%s|n from another of your sessions." + txt2 = "|c%s|n|R is now acted from another of your sessions.|n" + self.msg(txt1 % obj.name, session=session) + self.msg(txt2 % obj.name, session=obj.sessions.all()) + self.unpuppet_object(obj.sessions.get()) + elif obj.account.is_connected: + # controlled by another account + self.msg("|c%s|R is already puppeted by another Account." % obj.key) + return + + # do the puppeting + if session.puppet: + # cleanly unpuppet eventual previous object puppeted by this session + self.unpuppet_object(session) + # if we get to this point the character is ready to puppet or it + # was left with a lingering account/session reference from an unclean + # server kill or similar + + obj.at_pre_puppet(self, session=session) + + # do the connection + obj.sessions.add(session) + obj.account = self + session.puid = obj.id + session.puppet = obj + # validate/start persistent scripts on object + obj.scripts.validate() + + # re-cache locks to make sure superuser bypass is updated + obj.locks.cache_lock_bypass(obj) + # final hook + obj.at_post_puppet() + + def unpuppet_object(self, session): + """ + Disengage control over an object. + + Args: + session (Session or list): The session or a list of + sessions to disengage from their puppets. + + Raises: + RuntimeError With message about error. + + """ + for session in make_iter(session): + obj = session.puppet + if obj: + # do the disconnect, but only if we are the last session to puppet + obj.at_pre_unpuppet() + obj.sessions.remove(session) + if not obj.sessions.count(): + del obj.account + obj.at_post_unpuppet(self, session=session) + # Just to be sure we're always clear. + session.puppet = None + session.puid = None + + def unpuppet_all(self): + """ + Disconnect all puppets. This is called by server before a + reset/shutdown. + """ + self.unpuppet_object(self.sessions.all()) + + def get_puppet(self, session): + """ + Get an object puppeted by this session through this account. This is + the main method for retrieving the puppeted object from the + account's end. + + Args: + session (Session): Find puppeted object based on this session + + Returns: + puppet (Object): The matching puppeted object, if any. + + """ + return session.puppet + + def get_all_puppets(self): + """ + Get all currently puppeted objects. + + Returns: + puppets (list): All puppeted objects currently controlled + by this Account. + + """ + return list(set(session.puppet for session in self.sessions.all() if session.puppet)) + + def __get_single_puppet(self): + """ + This is a legacy convenience link for use with `MULTISESSION_MODE`. + + Returns: + puppets (Object or list): Users of `MULTISESSION_MODE` 0 or 1 will + always get the first puppet back. Users of higher `MULTISESSION_MODE`s will + get a list of all puppeted objects. + + """ + puppets = self.get_all_puppets() + if _MULTISESSION_MODE in (0, 1): + return puppets and puppets[0] or None + return puppets + character = property(__get_single_puppet) + puppet = property(__get_single_puppet) + + # utility methods + + def delete(self, *args, **kwargs): + """ + Deletes the account permanently. + + Notes: + `*args` and `**kwargs` are passed on to the base delete + mechanism (these are usually not used). + + """ + for session in self.sessions.all(): + # unpuppeting all objects and disconnecting the user, if any + # sessions remain (should usually be handled from the + # deleting command) + try: + self.unpuppet_object(session) + except RuntimeError: + # no puppet to disconnect from + pass + session.sessionhandler.disconnect(session, reason=_("Account being deleted.")) + self.scripts.stop() + self.attributes.clear() + self.nicks.clear() + self.aliases.clear() + super(DefaultAccount, self).delete(*args, **kwargs) + # methods inherited from database model + + def msg(self, text=None, from_obj=None, session=None, options=None, **kwargs): + """ + Evennia -> User + This is the main route for sending data back to the user from the + server. + + Args: + text (str, optional): text data to send + from_obj (Object or Account, optional): Object sending. If given, + its at_msg_send() hook will be called. + session (Session or list, optional): Session object or a list of + Sessions to receive this send. If given, overrules the + default send behavior for the current + MULTISESSION_MODE. + options (list): Protocol-specific options. Passed on to the protocol. + Kwargs: + any (dict): All other keywords are passed on to the protocol. + + """ + if from_obj: + # call hook + try: + from_obj.at_msg_send(text=text, to_obj=self, **kwargs) + except Exception: + # this may not be assigned. + pass + try: + if not self.at_msg_receive(text=text, **kwargs): + # abort message to this account + return + except Exception: + # this may not be assigned. + pass + + kwargs["options"] = options + + # session relay + sessions = make_iter(session) if session else self.sessions.all() + for session in sessions: + session.data_out(text=text, **kwargs) + + def execute_cmd(self, raw_string, session=None, **kwargs): + """ + Do something as this account. This method is never called normally, + but only when the account object itself is supposed to execute the + command. It takes account nicks into account, but not nicks of + eventual puppets. + + Args: + raw_string (str): Raw command input coming from the command line. + session (Session, optional): The session to be responsible + for the command-send + + Kwargs: + kwargs (any): Other keyword arguments will be added to the + found command object instance as variables before it + executes. This is unused by default Evennia but may be + used to set flags and change operating paramaters for + commands at run-time. + + """ + raw_string = to_unicode(raw_string) + raw_string = self.nicks.nickreplace(raw_string, categories=("inputline", "channel"), include_account=False) + if not session and _MULTISESSION_MODE in (0, 1): + # for these modes we use the first/only session + sessions = self.sessions.get() + session = sessions[0] if sessions else None + + return cmdhandler.cmdhandler(self, raw_string, + callertype="account", session=session, **kwargs) + + def search(self, searchdata, return_puppet=False, search_object=False, + typeclass=None, nofound_string=None, multimatch_string=None, **kwargs): + """ + This is similar to `DefaultObject.search` but defaults to searching + for Accounts only. + + Args: + searchdata (str or int): Search criterion, the Account's + key or dbref to search for. + return_puppet (bool, optional): Instructs the method to + return matches as the object the Account controls rather + than the Account itself (or None) if nothing is puppeted). + search_object (bool, optional): Search for Objects instead of + Accounts. This is used by e.g. the @examine command when + wanting to examine Objects while OOC. + typeclass (Account typeclass, optional): Limit the search + only to this particular typeclass. This can be used to + limit to specific account typeclasses or to limit the search + to a particular Object typeclass if `search_object` is True. + nofound_string (str, optional): A one-time error message + to echo if `searchdata` leads to no matches. If not given, + will fall back to the default handler. + multimatch_string (str, optional): A one-time error + message to echo if `searchdata` leads to multiple matches. + If not given, will fall back to the default handler. + + Return: + match (Account, Object or None): A single Account or Object match. + Notes: + Extra keywords are ignored, but are allowed in call in + order to make API more consistent with + objects.objects.DefaultObject.search. + + """ + # handle me, self and *me, *self + if isinstance(searchdata, basestring): + # handle wrapping of common terms + if searchdata.lower() in ("me", "*me", "self", "*self",): + return self + if search_object: + matches = ObjectDB.objects.object_search(searchdata, typeclass=typeclass) + else: + matches = AccountDB.objects.account_search(searchdata, typeclass=typeclass) + matches = _AT_SEARCH_RESULT(matches, self, query=searchdata, + nofound_string=nofound_string, + multimatch_string=multimatch_string) + if matches and return_puppet: + try: + return matches.puppet + except AttributeError: + return None + return matches + + def access(self, accessing_obj, access_type='read', default=False, no_superuser_bypass=False, **kwargs): + """ + Determines if another object has permission to access this + object in whatever way. + + Args: + accessing_obj (Object): Object trying to access this one. + access_type (str, optional): Type of access sought. + default (bool, optional): What to return if no lock of + access_type was found + no_superuser_bypass (bool, optional): Turn off superuser + lock bypassing. Be careful with this one. + + Kwargs: + kwargs (any): Passed to the at_access hook along with the result. + + Returns: + result (bool): Result of access check. + + """ + result = super(DefaultAccount, self).access(accessing_obj, access_type=access_type, + default=default, no_superuser_bypass=no_superuser_bypass) + self.at_access(result, accessing_obj, access_type, **kwargs) + return result + + @property + def idle_time(self): + """ + Returns the idle time of the least idle session in seconds. If + no sessions are connected it returns nothing. + """ + idle = [session.cmd_last_visible for session in self.sessions.all()] + if idle: + return time.time() - float(max(idle)) + return None + + @property + def connection_time(self): + """ + Returns the maximum connection time of all connected sessions + in seconds. Returns nothing if there are no sessions. + """ + conn = [session.conn_time for session in self.sessions.all()] + if conn: + return time.time() - float(min(conn)) + return None + + # account hooks + + def basetype_setup(self): + """ + This sets up the basic properties for an account. Overload this + with at_account_creation rather than changing this method. + + """ + # A basic security setup + lockstring = "examine:perm(Admin);edit:perm(Admin);" \ + "delete:perm(Admin);boot:perm(Admin);msg:all()" + self.locks.add(lockstring) + + # The ooc account cmdset + self.cmdset.add_default(_CMDSET_ACCOUNT, permanent=True) + + def at_account_creation(self): + """ + This is called once, the very first time the account is created + (i.e. first time they register with the game). It's a good + place to store attributes all accounts should have, like + configuration values etc. + + """ + # set an (empty) attribute holding the characters this account has + lockstring = "attrread:perm(Admins);attredit:perm(Admins);" \ + "attrcreate:perm(Admins)" + self.attributes.add("_playable_characters", [], lockstring=lockstring) + self.attributes.add("_saved_protocol_flags", {}, lockstring=lockstring) + + def at_init(self): + """ + This is always called whenever this object is initiated -- + that is, whenever it its typeclass is cached from memory. This + happens on-demand first time the object is used or activated + in some way after being created but also after each server + restart or reload. In the case of account objects, this usually + happens the moment the account logs in or reconnects after a + reload. + + """ + pass + + # Note that the hooks below also exist in the character object's + # typeclass. You can often ignore these and rely on the character + # ones instead, unless you are implementing a multi-character game + # and have some things that should be done regardless of which + # character is currently connected to this account. + + def at_first_save(self): + """ + This is a generic hook called by Evennia when this object is + saved to the database the very first time. You generally + don't override this method but the hooks called by it. + + """ + self.basetype_setup() + self.at_account_creation() + + permissions = settings.PERMISSION_ACCOUNT_DEFAULT + if hasattr(self, "_createdict"): + # this will only be set if the utils.create_account + # function was used to create the object. + cdict = self._createdict + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("permissions"): + permissions = cdict["permissions"] + del self._createdict + + self.permissions.batch_add(*permissions) + + def at_access(self, result, accessing_obj, access_type, **kwargs): + """ + This is triggered after an access-call on this Account has + completed. + + Args: + result (bool): The result of the access check. + accessing_obj (any): The object requesting the access + check. + access_type (str): The type of access checked. + + Kwargs: + kwargs (any): These are passed on from the access check + and can be used to relay custom instructions from the + check mechanism. + + Notes: + This method cannot affect the result of the lock check and + its return value is not used in any way. It can be used + e.g. to customize error messages in a central location or + create other effects based on the access result. + + """ + pass + + def at_cmdset_get(self, **kwargs): + """ + Called just *before* cmdsets on this account are requested by + the command handler. The cmdsets are available as + `self.cmdset`. If changes need to be done on the fly to the + cmdset before passing them on to the cmdhandler, this is the + place to do it. This is called also if the account currently + have no cmdsets. kwargs are usually not used unless the + cmdset is generated dynamically. + + """ + pass + + def at_first_login(self, **kwargs): + """ + Called the very first time this account logs into the game. + Note that this is called *before* at_pre_login, so no session + is established and usually no character is yet assigned at + this point. This hook is intended for account-specific setup + like configurations. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass + + def at_pre_login(self, **kwargs): + """ + Called every time the user logs in, just before the actual + login-state is set. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass + + def _send_to_connect_channel(self, message): + """ + Helper method for loading and sending to the comm channel + dedicated to connection messages. + + Args: + message (str): A message to send to the connect channel. + + """ + global _CONNECT_CHANNEL + if not _CONNECT_CHANNEL: + try: + _CONNECT_CHANNEL = ChannelDB.objects.filter(db_key=settings.DEFAULT_CHANNELS[1]["key"])[0] + except Exception: + logger.log_trace() + now = timezone.now() + now = "%02i-%02i-%02i(%02i:%02i)" % (now.year, now.month, + now.day, now.hour, now.minute) + if _CONNECT_CHANNEL: + _CONNECT_CHANNEL.tempmsg("[%s, %s]: %s" % (_CONNECT_CHANNEL.key, now, message)) + else: + logger.log_info("[%s]: %s" % (now, message)) + + def at_post_login(self, session=None, **kwargs): + """ + Called at the end of the login process, just before letting + the account loose. + + Args: + session (Session, optional): Session logging in, if any. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Notes: + This is called *before* an eventual Character's + `at_post_login` hook. By default it is used to set up + auto-puppeting based on `MULTISESSION_MODE`. + + """ + # if we have saved protocol flags on ourselves, load them here. + protocol_flags = self.attributes.get("_saved_protocol_flags", None) + if session and protocol_flags: + session.update_flags(**protocol_flags) + + # inform the client that we logged in through an OOB message + if session: + session.msg(logged_in={}) + + self._send_to_connect_channel("|G%s connected|n" % self.key) + if _MULTISESSION_MODE == 0: + # in this mode we should have only one character available. We + # try to auto-connect to our last conneted object, if any + try: + self.puppet_object(session, self.db._last_puppet) + except RuntimeError: + self.msg("The Character does not exist.") + return + elif _MULTISESSION_MODE == 1: + # in this mode all sessions connect to the same puppet. + try: + self.puppet_object(session, self.db._last_puppet) + except RuntimeError: + self.msg("The Character does not exist.") + return + elif _MULTISESSION_MODE in (2, 3): + # In this mode we by default end up at a character selection + # screen. We execute look on the account. + # we make sure to clean up the _playable_characers list in case + # any was deleted in the interim. + self.db._playable_characters = [char for char in self.db._playable_characters if char] + self.msg(self.at_look(target=self.db._playable_characters, + session=session)) + + def at_failed_login(self, session, **kwargs): + """ + Called by the login process if a user account is targeted correctly + but provided with an invalid password. By default it does nothing, + but exists to be overriden. + + Args: + session (session): Session logging in. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + """ + pass + + def at_disconnect(self, reason=None, **kwargs): + """ + Called just before user is disconnected. + + Args: + reason (str, optional): The reason given for the disconnect, + (echoed to the connection channel by default). + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + + """ + reason = reason and "(%s)" % reason or "" + self._send_to_connect_channel("|R%s disconnected %s|n" % (self.key, reason)) + + def at_post_disconnect(self, **kwargs): + """ + This is called *after* disconnection is complete. No messages + can be relayed to the account from here. After this call, the + account should not be accessed any more, making this a good + spot for deleting it (in the case of a guest account account, + for example). + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass + + def at_message_receive(self, message, from_obj=None, **kwargs): + """ + This is currently unused. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + return True + + def at_message_send(self, message, to_object, **kwargs): + """ + This is currently unused. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + pass + + def at_server_reload(self): + """ + This hook is called whenever the server is shutting down for + restart/reboot. If you want to, for example, save + non-persistent properties across a restart, this is the place + to do it. + """ + pass + + def at_server_shutdown(self): + """ + This hook is called whenever the server is shutting down fully + (i.e. not for a restart). + """ + pass + + def at_look(self, target=None, session=None, **kwargs): + """ + Called when this object executes a look. It allows to customize + just what this means. + + Args: + target (Object or list, optional): An object or a list + objects to inspect. + session (Session, optional): The session doing this look. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + look_string (str): A prepared look string, ready to send + off to any recipient (usually to ourselves) + + """ + + if target and not is_iter(target): + # single target - just show it + return target.return_appearance(self) + else: + # list of targets - make list to disconnect from db + characters = list(tar for tar in target if tar) if target else [] + sessions = self.sessions.all() + is_su = self.is_superuser + + # text shown when looking in the ooc area + result = ["Account |g%s|n (you are Out-of-Character)" % self.key] + + nsess = len(sessions) + result.append(nsess == 1 and "\n\n|wConnected session:|n" or "\n\n|wConnected sessions (%i):|n" % nsess) + for isess, sess in enumerate(sessions): + csessid = sess.sessid + addr = "%s (%s)" % (sess.protocol_key, isinstance(sess.address, tuple) + and str(sess.address[0]) or str(sess.address)) + result.append("\n %s %s" % (session.sessid == csessid and "|w* %s|n" % (isess + 1) + or " %s" % (isess + 1), addr)) + result.append("\n\n |whelp|n - more commands") + result.append("\n |wooc |n - talk on public channel") + + charmax = _MAX_NR_CHARACTERS if _MULTISESSION_MODE > 1 else 1 + + if is_su or len(characters) < charmax: + if not characters: + result.append("\n\n You don't have any characters yet. See |whelp @charcreate|n for creating one.") + else: + result.append("\n |w@charcreate [=description]|n - create new character") + result.append("\n |w@chardelete |n - delete a character (cannot be undone!)") + + if characters: + string_s_ending = len(characters) > 1 and "s" or "" + result.append("\n |w@ic |n - enter the game (|w@ooc|n to get back here)") + if is_su: + result.append("\n\nAvailable character%s (%i/unlimited):" % (string_s_ending, len(characters))) + else: + result.append("\n\nAvailable character%s%s:" + % (string_s_ending, charmax > 1 and " (%i/%i)" % (len(characters), charmax) or "")) + + for char in characters: + csessions = char.sessions.all() + if csessions: + for sess in csessions: + # character is already puppeted + sid = sess in sessions and sessions.index(sess) + 1 + if sess and sid: + result.append("\n - |G%s|n [%s] (played by you in session %i)" + % (char.key, ", ".join(char.permissions.all()), sid)) + else: + result.append("\n - |R%s|n [%s] (played by someone else)" + % (char.key, ", ".join(char.permissions.all()))) + else: + # character is "free to puppet" + result.append("\n - %s [%s]" % (char.key, ", ".join(char.permissions.all()))) + look_string = ("-" * 68) + "\n" + "".join(result) + "\n" + ("-" * 68) + return look_string + + +class DefaultGuest(DefaultAccount): + """ + This class is used for guest logins. Unlike Accounts, Guests and + their characters are deleted after disconnection. + """ + def at_post_login(self, session=None, **kwargs): + """ + In theory, guests only have one character regardless of which + MULTISESSION_MODE we're in. They don't get a choice. + + Args: + session (Session, optional): Session connecting. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + self._send_to_connect_channel("|G%s connected|n" % self.key) + self.puppet_object(session, self.db._last_puppet) + + def at_server_shutdown(self): + """ + We repeat the functionality of `at_disconnect()` here just to + be on the safe side. + """ + super(DefaultGuest, self).at_server_shutdown() + characters = self.db._playable_characters + for character in characters: + if character: + print "deleting Character:", character + character.delete() + + def at_post_disconnect(self, **kwargs): + """ + Once having disconnected, destroy the guest's characters and + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + super(DefaultGuest, self).at_post_disconnect() + characters = self.db._playable_characters + for character in characters: + if character: + character.delete() + self.delete() diff --git a/evennia/accounts/admin.py b/evennia/accounts/admin.py new file mode 100644 index 0000000000..d02b1baeac --- /dev/null +++ b/evennia/accounts/admin.py @@ -0,0 +1,255 @@ +# +# This sets up how models are displayed +# in the web admin interface. +# +from builtins import object + +from django import forms +from django.conf import settings +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.forms import UserChangeForm, UserCreationForm +from evennia.accounts.models import AccountDB +from evennia.typeclasses.admin import AttributeInline, TagInline +from evennia.utils import create + + +# handle the custom User editor +class AccountDBChangeForm(UserChangeForm): + """ + Modify the accountdb class. + + """ + class Meta(object): + model = AccountDB + fields = '__all__' + + username = forms.RegexField( + label="Username", + max_length=30, + regex=r'^[\w. @+-]+$', + widget=forms.TextInput( + attrs={'size': '30'}), + error_messages={ + 'invalid': "This value may contain only letters, spaces, numbers " + "and @/./+/-/_ characters."}, + help_text="30 characters or fewer. Letters, spaces, digits and " + "@/./+/-/_ only.") + + def clean_username(self): + """ + Clean the username and check its existence. + + """ + username = self.cleaned_data['username'] + if username.upper() == self.instance.username.upper(): + return username + elif AccountDB.objects.filter(username__iexact=username): + raise forms.ValidationError('An account with that name ' + 'already exists.') + return self.cleaned_data['username'] + + +class AccountDBCreationForm(UserCreationForm): + """ + Create a new AccountDB instance. + """ + + class Meta(object): + model = AccountDB + fields = '__all__' + + username = forms.RegexField( + label="Username", + max_length=30, + regex=r'^[\w. @+-]+$', + widget=forms.TextInput( + attrs={'size': '30'}), + error_messages={ + 'invalid': "This value may contain only letters, spaces, numbers " + "and @/./+/-/_ characters."}, + help_text="30 characters or fewer. Letters, spaces, digits and " + "@/./+/-/_ only.") + + def clean_username(self): + """ + Cleanup username. + """ + username = self.cleaned_data['username'] + if AccountDB.objects.filter(username__iexact=username): + raise forms.ValidationError('An account with that name already ' + 'exists.') + return username + + +class AccountForm(forms.ModelForm): + """ + Defines how to display Accounts + + """ + class Meta(object): + model = AccountDB + fields = '__all__' + + db_key = forms.RegexField( + label="Username", + initial="AccountDummy", + max_length=30, + regex=r'^[\w. @+-]+$', + required=False, + widget=forms.TextInput(attrs={'size': '30'}), + error_messages={ + 'invalid': "This value may contain only letters, spaces, numbers" + " and @/./+/-/_ characters."}, + help_text="This should be the same as the connected Account's key " + "name. 30 characters or fewer. Letters, spaces, digits and " + "@/./+/-/_ only.") + + db_typeclass_path = forms.CharField( + label="Typeclass", + initial=settings.BASE_PLAYER_TYPECLASS, + widget=forms.TextInput( + attrs={'size': '78'}), + help_text="Required. Defines what 'type' of entity this is. This " + "variable holds a Python path to a module with a valid " + "Evennia Typeclass. Defaults to " + "settings.BASE_ACCOUNT_TYPECLASS.") + + db_permissions = forms.CharField( + label="Permissions", + initial=settings.PERMISSION_PLAYER_DEFAULT, + required=False, + widget=forms.TextInput( + attrs={'size': '78'}), + help_text="In-game permissions. A comma-separated list of text " + "strings checked by certain locks. They are often used for " + "hierarchies, such as letting an Account have permission " + "'Admin', 'Builder' etc. An Account permission can be " + "overloaded by the permissions of a controlled Character. " + "Normal accounts use 'Accounts' by default.") + + db_lock_storage = forms.CharField( + label="Locks", + widget=forms.Textarea(attrs={'cols': '100', 'rows': '2'}), + required=False, + help_text="In-game lock definition string. If not given, defaults " + "will be used. This string should be on the form " + "type:lockfunction(args);type2:lockfunction2(args);...") + db_cmdset_storage = forms.CharField( + label="cmdset", + initial=settings.CMDSET_PLAYER, + widget=forms.TextInput(attrs={'size': '78'}), + required=False, + help_text="python path to account cmdset class (set in " + "settings.CMDSET_ACCOUNT by default)") + + +class AccountInline(admin.StackedInline): + """ + Inline creation of Account + + """ + model = AccountDB + template = "admin/accounts/stacked.html" + form = AccountForm + fieldsets = ( + ("In-game Permissions and Locks", + {'fields': ('db_lock_storage',), + #{'fields': ('db_permissions', 'db_lock_storage'), + 'description': "These are permissions/locks for in-game use. " + "They are unrelated to website access rights."}), + ("In-game Account data", + {'fields': ('db_typeclass_path', 'db_cmdset_storage'), + 'description': "These fields define in-game-specific properties " + "for the Account object in-game."})) + + extra = 1 + max_num = 1 + + +class AccountTagInline(TagInline): + """ + Inline Account Tags. + + """ + model = AccountDB.db_tags.through + related_field = "accountdb" + + +class AccountAttributeInline(AttributeInline): + """ + Inline Account Attributes. + + """ + model = AccountDB.db_attributes.through + related_field = "accountdb" + + +class AccountDBAdmin(BaseUserAdmin): + """ + This is the main creation screen for Users/accounts + + """ + + list_display = ('username', 'email', 'is_staff', 'is_superuser') + form = AccountDBChangeForm + add_form = AccountDBCreationForm + inlines = [AccountTagInline, AccountAttributeInline] + fieldsets = ( + (None, {'fields': ('username', 'password', 'email')}), + ('Website profile', { + 'fields': ('first_name', 'last_name'), + 'description': "These are not used " + "in the default system."}), + ('Website dates', { + 'fields': ('last_login', 'date_joined'), + 'description': 'Relevant only to the website.'}), + ('Website Permissions', { + 'fields': ('is_active', 'is_staff', 'is_superuser', + 'user_permissions', 'groups'), + 'description': "These are permissions/permission groups for " + "accessing the admin site. They are unrelated to " + "in-game access rights."}), + ('Game Options', { + 'fields': ('db_typeclass_path', 'db_cmdset_storage', + 'db_lock_storage'), + 'description': 'These are attributes that are more relevant ' + 'to gameplay.'})) + # ('Game Options', {'fields': ( + # 'db_typeclass_path', 'db_cmdset_storage', + # 'db_permissions', 'db_lock_storage'), + # 'description': 'These are attributes that are ' + # 'more relevant to gameplay.'})) + + add_fieldsets = ( + (None, + {'fields': ('username', 'password1', 'password2', 'email'), + 'description': "These account details are shared by the admin " + "system and the game."},),) + + def save_model(self, request, obj, form, change): + """ + Custom save actions. + + Args: + request (Request): Incoming request. + obj (Object): Object to save. + form (Form): Related form instance. + change (bool): False if this is a new save and not an update. + + """ + obj.save() + if not change: + #calling hooks for new account + obj.set_class_from_typeclass(typeclass_path=settings.BASE_PLAYER_TYPECLASS) + obj.basetype_setup() + obj.at_account_creation() + + def response_add(self, request, obj, post_url_continue=None): + from django.http import HttpResponseRedirect + from django.core.urlresolvers import reverse + if '_continue' in request.POST: + return HttpResponseRedirect(reverse("admin:accounts_accountdb_change", args=[obj.id])) + return HttpResponseRedirect(reverse("admin:accounts_accountdb_change", args=[obj.id])) + +admin.site.register(AccountDB, AccountDBAdmin) diff --git a/evennia/accounts/bots.py b/evennia/accounts/bots.py new file mode 100644 index 0000000000..091370ef08 --- /dev/null +++ b/evennia/accounts/bots.py @@ -0,0 +1,419 @@ +""" +Bots are a special child typeclasses of +Account that are controlled by the server. + +""" +from __future__ import print_function +import time +from django.conf import settings +from evennia.accounts.accounts import DefaultAccount +from evennia.scripts.scripts import DefaultScript +from evennia.utils import search +from evennia.utils import utils + +_IDLE_TIMEOUT = settings.IDLE_TIMEOUT + +_IRC_ENABLED = settings.IRC_ENABLED +_RSS_ENABLED = settings.RSS_ENABLED + +_SESSIONS = None + + +# Bot helper utilities + +class BotStarter(DefaultScript): + """ + This non-repeating script has the + sole purpose of kicking its bot + into gear when it is initialized. + + """ + def at_script_creation(self): + """ + Called once, when script is created. + + """ + self.key = "botstarter" + self.desc = "bot start/keepalive" + self.persistent = True + self.db.started = False + if _IDLE_TIMEOUT > 0: + # call before idle_timeout triggers + self.interval = int(max(60, _IDLE_TIMEOUT * 0.90)) + self.start_delay = True + + def at_start(self): + """ + Kick bot into gear. + + """ + if not self.db.started: + self.account.start() + self.db.started = True + + def at_repeat(self): + """ + Called self.interval seconds to keep connection. We cannot use + the IDLE command from inside the game since the system will + not catch it (commands executed from the server side usually + has no sessions). So we update the idle counter manually here + instead. This keeps the bot getting hit by IDLE_TIMEOUT. + + """ + global _SESSIONS + if not _SESSIONS: + from evennia.server.sessionhandler import SESSIONS as _SESSIONS + for session in _SESSIONS.sessions_from_account(self.account): + session.update_session_counters(idle=True) + + def at_server_reload(self): + """ + If server reloads we don't need to reconnect the protocol + again, this is handled by the portal reconnect mechanism. + + """ + self.db.started = True + + def at_server_shutdown(self): + """ + Make sure we are shutdown. + + """ + self.db.started = False + +# +# Bot base class + + +class Bot(DefaultAccount): + """ + A Bot will start itself when the server starts (it will generally + not do so on a reload - that will be handled by the normal Portal + session resync) + + """ + + def basetype_setup(self): + """ + This sets up the basic properties for the bot. + + """ + # the text encoding to use. + self.db.encoding = "utf-8" + # A basic security setup + lockstring = "examine:perm(Admin);edit:perm(Admin);delete:perm(Admin);boot:perm(Admin);msg:false()" + self.locks.add(lockstring) + # set the basics of being a bot + script_key = "%s" % self.key + self.scripts.add(BotStarter, key=script_key) + self.is_bot = True + + def start(self, **kwargs): + """ + This starts the bot, whatever that may mean. + + """ + pass + + def msg(self, text=None, from_obj=None, session=None, options=None, **kwargs): + """ + Evennia -> outgoing protocol + + """ + super(Bot, self).msg(text=text, from_obj=from_obj, session=session, options=options, **kwargs) + + def execute_cmd(self, raw_string, session=None): + """ + Incoming protocol -> Evennia + + """ + super(Bot, self).msg(raw_string, session=session) + + def at_server_shutdown(self): + """ + We need to handle this case manually since the shutdown may be + a reset. + + """ + for session in self.sessions.all(): + session.sessionhandler.disconnect(session) + + +# Bot implementations + +# IRC + +class IRCBot(Bot): + """ + Bot for handling IRC connections. + + """ + def start(self, ev_channel=None, irc_botname=None, irc_channel=None, irc_network=None, irc_port=None, irc_ssl=None): + """ + Start by telling the portal to start a new session. + + Args: + ev_channel (str): Key of the Evennia channel to connect to. + irc_botname (str): Name of bot to connect to irc channel. If + not set, use `self.key`. + irc_channel (str): Name of channel on the form `#channelname`. + irc_network (str): URL of the IRC network, like `irc.freenode.net`. + irc_port (str): Port number of the irc network, like `6667`. + irc_ssl (bool): Indicates whether to use SSL connection. + + """ + if not _IRC_ENABLED: + # the bot was created, then IRC was turned off. We delete + # ourselves (this will also kill the start script) + self.delete() + return + + global _SESSIONS + if not _SESSIONS: + from evennia.server.sessionhandler import SESSIONS as _SESSIONS + + # if keywords are given, store (the BotStarter script + # will not give any keywords, so this should normally only + # happen at initialization) + if irc_botname: + self.db.irc_botname = irc_botname + elif not self.db.irc_botname: + self.db.irc_botname = self.key + if ev_channel: + # connect to Evennia channel + channel = search.channel_search(ev_channel) + if not channel: + raise RuntimeError("Evennia Channel '%s' not found." % ev_channel) + channel = channel[0] + channel.connect(self) + self.db.ev_channel = channel + if irc_channel: + self.db.irc_channel = irc_channel + if irc_network: + self.db.irc_network = irc_network + if irc_port: + self.db.irc_port = irc_port + if irc_ssl: + self.db.irc_ssl = irc_ssl + + # instruct the server and portal to create a new session with + # the stored configuration + configdict = {"uid": self.dbid, + "botname": self.db.irc_botname, + "channel": self.db.irc_channel, + "network": self.db.irc_network, + "port": self.db.irc_port, + "ssl": self.db.irc_ssl} + _SESSIONS.start_bot_session("evennia.server.portal.irc.IRCBotFactory", configdict) + + def get_nicklist(self, caller): + """ + Retrive the nick list from the connected channel. + + Args: + caller (Object or Account): The requester of the list. This will + be stored and echoed to when the irc network replies with the + requested info. + + Notes: Since the return is asynchronous, the caller is stored internally + in a list; all callers in this list will get the nick info once it + returns (it is a custom OOB inputfunc option). The callback will not + survive a reload (which should be fine, it's very quick). + """ + if not hasattr(self, "_nicklist_callers"): + self._nicklist_callers = [] + self._nicklist_callers.append(caller) + super(IRCBot, self).msg(request_nicklist="") + return + + def ping(self, caller): + """ + Fire a ping to the IRC server. + + Args: + caller (Object or Account): The requester of the ping. + + """ + if not hasattr(self, "_ping_callers"): + self._ping_callers = [] + self._ping_callers.append(caller) + super(IRCBot, self).msg(ping="") + + def reconnect(self): + """ + Force a protocol-side reconnect of the client without + having to destroy/recreate the bot "account". + + """ + super(IRCBot, self).msg(reconnect="") + + def msg(self, text=None, **kwargs): + """ + Takes text from connected channel (only). + + Args: + text (str, optional): Incoming text from channel. + + Kwargs: + options (dict): Options dict with the following allowed keys: + - from_channel (str): dbid of a channel this text originated from. + - from_obj (list): list of objects this text. + + """ + from_obj = kwargs.get("from_obj", None) + options = kwargs.get("options", None) or {} + if not self.ndb.ev_channel and self.db.ev_channel: + # cache channel lookup + self.ndb.ev_channel = self.db.ev_channel + if "from_channel" in options and text and self.ndb.ev_channel.dbid == options["from_channel"]: + if not from_obj or from_obj != [self.id]: + super(IRCBot, self).msg(channel=text) + + def execute_cmd(self, session=None, txt=None, **kwargs): + """ + Take incoming data and send it to connected channel. This is + triggered by the bot_data_in Inputfunc. + + Args: + session (Session, optional): Session responsible for this + command. Note that this is the bot. + txt (str, optional): Command string. + Kwargs: + user (str): The name of the user who sent the message. + channel (str): The name of channel the message was sent to. + type (str): Nature of message. Either 'msg', 'action', 'nicklist' or 'ping'. + nicklist (list, optional): Set if `type='nicklist'`. This is a list of nicks returned by calling + the `self.get_nicklist`. It must look for a list `self._nicklist_callers` + which will contain all callers waiting for the nicklist. + timings (float, optional): Set if `type='ping'`. This is the return (in seconds) of a + ping request triggered with `self.ping`. The return must look for a list + `self._ping_callers` which will contain all callers waiting for the ping return. + + """ + if kwargs["type"] == "nicklist": + # the return of a nicklist request + if hasattr(self, "_nicklist_callers") and self._nicklist_callers: + chstr = "%s (%s:%s)" % (self.db.irc_channel, self.db.irc_network, self.db.irc_port) + nicklist = ", ".join(sorted(kwargs["nicklist"], key=lambda n: n.lower())) + for obj in self._nicklist_callers: + obj.msg("Nicks at %s:\n %s" % (chstr, nicklist)) + self._nicklist_callers = [] + return + + elif kwargs["type"] == "ping": + # the return of a ping + if hasattr(self, "_ping_callers") and self._ping_callers: + chstr = "%s (%s:%s)" % (self.db.irc_channel, self.db.irc_network, self.db.irc_port) + for obj in self._ping_callers: + obj.msg("IRC ping return from %s took %ss." % (chstr, kwargs["timing"])) + self._ping_callers = [] + return + + elif kwargs["type"] == "privmsg": + # A private message to the bot - a command. + user = kwargs["user"] + + if txt.lower().startswith("who"): + # return server WHO list (abbreviated for IRC) + global _SESSIONS + if not _SESSIONS: + from evennia.server.sessionhandler import SESSIONS as _SESSIONS + whos = [] + t0 = time.time() + for sess in _SESSIONS.get_sessions(): + delta_cmd = t0 - sess.cmd_last_visible + delta_conn = t0 - session.conn_time + account = sess.get_account() + whos.append("%s (%s/%s)" % (utils.crop("|w%s|n" % account.name, width=25), + utils.time_format(delta_conn, 0), + utils.time_format(delta_cmd, 1))) + text = "Who list (online/idle): %s" % ", ".join(sorted(whos, key=lambda w: w.lower())) + elif txt.lower().startswith("about"): + # some bot info + text = "This is an Evennia IRC bot connecting from '%s'." % settings.SERVERNAME + else: + text = "I understand 'who' and 'about'." + super(IRCBot, self).msg(privmsg=((text,), {"user": user})) + else: + # something to send to the main channel + if kwargs["type"] == "action": + # An action (irc pose) + text = "%s@%s %s" % (kwargs["user"], kwargs["channel"], txt) + else: + # msg - A normal channel message + text = "%s@%s: %s" % (kwargs["user"], kwargs["channel"], txt) + + if not self.ndb.ev_channel and self.db.ev_channel: + # cache channel lookup + self.ndb.ev_channel = self.db.ev_channel + if self.ndb.ev_channel: + self.ndb.ev_channel.msg(text, senders=self.id) + +# +# RSS + + +class RSSBot(Bot): + """ + An RSS relayer. The RSS protocol itself runs a ticker to update + its feed at regular intervals. + + """ + def start(self, ev_channel=None, rss_url=None, rss_rate=None): + """ + Start by telling the portal to start a new RSS session + + Args: + ev_channel (str): Key of the Evennia channel to connect to. + rss_url (str): Full URL to the RSS feed to subscribe to. + rss_rate (int): How often for the feedreader to update. + + Raises: + RuntimeError: If `ev_channel` does not exist. + + """ + if not _RSS_ENABLED: + # The bot was created, then RSS was turned off. Delete ourselves. + self.delete() + return + + global _SESSIONS + if not _SESSIONS: + from evennia.server.sessionhandler import SESSIONS as _SESSIONS + + if ev_channel: + # connect to Evennia channel + channel = search.channel_search(ev_channel) + if not channel: + raise RuntimeError("Evennia Channel '%s' not found." % ev_channel) + channel = channel[0] + self.db.ev_channel = channel + if rss_url: + self.db.rss_url = rss_url + if rss_rate: + self.db.rss_rate = rss_rate + # instruct the server and portal to create a new session with + # the stored configuration + configdict = {"uid": self.dbid, + "url": self.db.rss_url, + "rate": self.db.rss_rate} + _SESSIONS.start_bot_session("evennia.server.portal.rss.RSSBotFactory", configdict) + + def execute_cmd(self, txt=None, session=None, **kwargs): + """ + Take incoming data and send it to connected channel. This is + triggered by the bot_data_in Inputfunc. + + Args: + session (Session, optional): Session responsible for this + command. + txt (str, optional): Command string. + kwargs (dict, optional): Additional Information passed from bot. + Not used by the RSSbot by default. + + """ + if not self.ndb.ev_channel and self.db.ev_channel: + # cache channel lookup + self.ndb.ev_channel = self.db.ev_channel + if self.ndb.ev_channel: + self.ndb.ev_channel.msg(txt, senders=self.id) diff --git a/evennia/accounts/manager.py b/evennia/accounts/manager.py new file mode 100644 index 0000000000..8156f03952 --- /dev/null +++ b/evennia/accounts/manager.py @@ -0,0 +1,180 @@ +""" +The managers for the custom Account object and permissions. +""" + +import datetime +from django.utils import timezone +from django.contrib.auth.models import UserManager +from evennia.typeclasses.managers import (TypedObjectManager, TypeclassManager) +__all__ = ("AccountManager",) + + +# +# Account Manager +# + +class AccountDBManager(TypedObjectManager, UserManager): + """ + This AccountManager implements methods for searching + and manipulating Accounts directly from the database. + + Evennia-specific search methods (will return Characters if + possible or a Typeclass/list of Typeclassed objects, whereas + Django-general methods will return Querysets or database objects): + + dbref (converter) + dbref_search + get_dbref_range + object_totals + typeclass_search + num_total_accounts + get_connected_accounts + get_recently_created_accounts + get_recently_connected_accounts + get_account_from_email + get_account_from_uid + get_account_from_name + account_search (equivalent to evennia.search_account) + #swap_character + + """ + def num_total_accounts(self): + """ + Get total number of accounts. + + Returns: + count (int): The total number of registered accounts. + + """ + return self.count() + + def get_connected_accounts(self): + """ + Get all currently connected accounts. + + Returns: + count (list): Account objects with currently + connected sessions. + + """ + return self.filter(db_is_connected=True) + + def get_recently_created_accounts(self, days=7): + """ + Get accounts recently created. + + Args: + days (int, optional): How many days in the past "recently" means. + + Returns: + accounts (list): The Accounts created the last `days` interval. + + """ + end_date = timezone.now() + tdelta = datetime.timedelta(days) + start_date = end_date - tdelta + return self.filter(date_joined__range=(start_date, end_date)) + + def get_recently_connected_accounts(self, days=7): + """ + Get accounts recently connected to the game. + + Args: + days (int, optional): Number of days backwards to check + + Returns: + accounts (list): The Accounts connected to the game in the + last `days` interval. + + """ + end_date = timezone.now() + tdelta = datetime.timedelta(days) + start_date = end_date - tdelta + return self.filter(last_login__range=( + start_date, end_date)).order_by('-last_login') + + def get_account_from_email(self, uemail): + """ + Search account by + Returns an account object based on email address. + + Args: + uemail (str): An email address to search for. + + Returns: + account (Account): A found account, if found. + + """ + return self.filter(email__iexact=uemail) + + def get_account_from_uid(self, uid): + """ + Get an account by id. + + Args: + uid (int): Account database id. + + Returns: + account (Account): The result. + + """ + try: + return self.get(id=uid) + except self.model.DoesNotExist: + return None + + def get_account_from_name(self, uname): + """ + Get account object based on name. + + Args: + uname (str): The Account name to search for. + + Returns: + account (Account): The found account. + + """ + try: + return self.get(username__iexact=uname) + except self.model.DoesNotExist: + return None + + def search_account(self, ostring, exact=True, typeclass=None): + """ + Searches for a particular account by name or + database id. + + Args: + ostring (str or int): A key string or database id. + exact (bool, optional): Only valid for string matches. If + `True`, requires exact (non-case-sensitive) match, + otherwise also match also keys containing the `ostring` + (non-case-sensitive fuzzy match). + typeclass (str or Typeclass, optional): Limit the search only to + accounts of this typeclass. + + """ + dbref = self.dbref(ostring) + if dbref or dbref == 0: + # bref search is always exact + matches = self.filter(id=dbref) + if matches: + return matches + query = {"username__iexact" if exact else "username__icontains": ostring} + if typeclass: + # we accept both strings and actual typeclasses + if callable(typeclass): + typeclass = u"%s.%s" % (typeclass.__module__, typeclass.__name__) + else: + typeclass = u"%s" % typeclass + query["db_typeclass_path"] = typeclass + if exact: + return self.filter(**query) + else: + return self.filter(**query) + # back-compatibility alias + account_search = search_account + + +class AccountManager(AccountDBManager, TypeclassManager): + pass diff --git a/evennia/accounts/migrations/0001_initial.py b/evennia/accounts/migrations/0001_initial.py new file mode 100644 index 0000000000..85e2303490 --- /dev/null +++ b/evennia/accounts/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2017-07-03 19:13 +from __future__ import unicode_literals + +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone +import evennia.accounts.manager + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ('typeclasses', '0008_lock_and_perm_rename'), + ] + + operations = [ + migrations.CreateModel( + name='AccountDB', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.ASCIIUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('db_key', models.CharField(db_index=True, max_length=255, verbose_name=b'key')), + ('db_typeclass_path', models.CharField(help_text=b"this defines what 'type' of entity this is. This variable holds a Python path to a module with a valid Evennia Typeclass.", max_length=255, null=True, verbose_name=b'typeclass')), + ('db_date_created', models.DateTimeField(auto_now_add=True, verbose_name=b'creation date')), + ('db_lock_storage', models.TextField(blank=True, help_text=b"locks limit access to an entity. A lock is defined as a 'lock string' on the form 'type:lockfunctions', defining what functionality is locked and how to determine access. Not defining a lock means no access is granted.", verbose_name=b'locks')), + ('db_is_connected', models.BooleanField(default=False, help_text=b'If player is connected to game or not', verbose_name=b'is_connected')), + ('db_cmdset_storage', models.CharField(help_text=b'optional python path to a cmdset class. If creating a Character, this will default to settings.CMDSET_CHARACTER.', max_length=255, null=True, verbose_name=b'cmdset')), + ('db_is_bot', models.BooleanField(default=False, help_text=b'Used to identify irc/rss bots', verbose_name=b'is_bot')), + ('db_attributes', models.ManyToManyField(help_text=b'attributes on this object. An attribute can hold any pickle-able python object (see docs for special cases).', to='typeclasses.Attribute')), + ('db_tags', models.ManyToManyField(help_text=b'tags on this object. Tags are simple string markers to identify, group and alias objects.', to='typeclasses.Tag')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'Account', + }, + managers=[ + ('objects', evennia.accounts.manager.AccountDBManager()), + ], + ), + ] diff --git a/evennia/accounts/migrations/0002_copy_player_to_account.py b/evennia/accounts/migrations/0002_copy_player_to_account.py new file mode 100644 index 0000000000..5c32856c4b --- /dev/null +++ b/evennia/accounts/migrations/0002_copy_player_to_account.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2017-07-03 19:17 +from __future__ import unicode_literals + +from django.apps import apps as global_apps +from django.db import migrations + + +def forwards(apps, schema_editor): + try: + PlayerDB = apps.get_model('players', 'PlayerDB') + except LookupError: + # playerdb not available. Skip. + return + + AccountDB = apps.get_model('accounts', 'AccountDB') + for player in PlayerDB.objects.all(): + account = AccountDB(id=player.id, + password=player.password, + is_superuser=player.is_superuser, + last_login=player.last_login, + username=player.username, + first_name=player.first_name, + last_name=player.last_name, + email=player.email, + is_staff=player.is_staff, + is_active=player.is_active, + date_joined=player.date_joined, + db_key=player.db_key, + db_typeclass_path=player.db_typeclass_path, + db_date_created=player.db_date_created, + db_lock_storage=player.db_lock_storage, + db_is_connected=player.db_is_connected, + db_cmdset_storage=player.db_cmdset_storage, + db_is_bot=player.db_is_bot) + account.save() + for group in player.groups.all(): + account.groups.add(group) + for user_permission in player.user_permissions.all(): + account.user_permissions.add(user_permission) + for attr in player.db_attributes.all(): + account.db_attributes.add(attr) + for tag in player.db_tags.all(): + account.db_tags.add(tag) + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.RunPython(forwards, migrations.RunPython.noop) + ] + + if global_apps.is_installed('players'): + dependencies.append(('players', '0006_auto_20170606_1731')) diff --git a/evennia/accounts/migrations/__init__.py b/evennia/accounts/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/accounts/models.py b/evennia/accounts/models.py new file mode 100644 index 0000000000..6b09064f10 --- /dev/null +++ b/evennia/accounts/models.py @@ -0,0 +1,169 @@ +""" +Account + +The account class is an extension of the default Django user class, +and is customized for the needs of Evennia. + +We use the Account to store a more mud-friendly style of permission +system as well as to allow the admin more flexibility by storing +attributes on the Account. Within the game we should normally use the +Account manager's methods to create users so that permissions are set +correctly. + +To make the Account model more flexible for your own game, it can also +persistently store attributes of its own. This is ideal for extra +account info and OOC account configuration variables etc. + +""" +from builtins import object + +from django.conf import settings +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.utils.encoding import smart_str + +from evennia.accounts.manager import AccountDBManager +from evennia.typeclasses.models import TypedObject +from evennia.utils.utils import make_iter + +__all__ = ("AccountDB",) + +#_ME = _("me") +#_SELF = _("self") + +_MULTISESSION_MODE = settings.MULTISESSION_MODE + +_GA = object.__getattribute__ +_SA = object.__setattr__ +_DA = object.__delattr__ + +_TYPECLASS = None + + +#------------------------------------------------------------ +# +# AccountDB +# +#------------------------------------------------------------ + +class AccountDB(TypedObject, AbstractUser): + """ + This is a special model using Django's 'profile' functionality + and extends the default Django User model. It is defined as such + by use of the variable AUTH_PROFILE_MODULE in the settings. + One accesses the fields/methods. We try use this model as much + as possible rather than User, since we can customize this to + our liking. + + The TypedObject supplies the following (inherited) properties: + + - key - main name + - typeclass_path - the path to the decorating typeclass + - typeclass - auto-linked typeclass + - date_created - time stamp of object creation + - permissions - perm strings + - dbref - #id of object + - db - persistent attribute storage + - ndb - non-persistent attribute storage + + The AccountDB adds the following properties: + + - is_connected - If any Session is currently connected to this Account + - name - alias for user.username + - sessions - sessions connected to this account + - is_superuser - bool if this account is a superuser + - is_bot - bool if this account is a bot and not a real account + + """ + + # + # AccountDB Database model setup + # + # inherited fields (from TypedObject): + # db_key, db_typeclass_path, db_date_created, db_permissions + + # store a connected flag here too, not just in sessionhandler. + # This makes it easier to track from various out-of-process locations + db_is_connected = models.BooleanField(default=False, + verbose_name="is_connected", + help_text="If player is connected to game or not") + # database storage of persistant cmdsets. + db_cmdset_storage = models.CharField('cmdset', max_length=255, null=True, + help_text="optional python path to a cmdset class. If creating a Character, this will default to settings.CMDSET_CHARACTER.") + # marks if this is a "virtual" bot account object + db_is_bot = models.BooleanField(default=False, verbose_name="is_bot", help_text="Used to identify irc/rss bots") + + # Database manager + objects = AccountDBManager() + + # defaults + __settingsclasspath__ = settings.BASE_SCRIPT_TYPECLASS + __defaultclasspath__ = "evennia.accounts.accounts.DefaultAccount" + __applabel__ = "accounts" + + class Meta(object): + verbose_name = 'Account' + + # cmdset_storage property + # This seems very sensitive to caching, so leaving it be for now /Griatch + #@property + def __cmdset_storage_get(self): + """ + Getter. Allows for value = self.name. Returns a list of cmdset_storage. + """ + storage = self.db_cmdset_storage + # we need to check so storage is not None + return [path.strip() for path in storage.split(',')] if storage else [] + + #@cmdset_storage.setter + def __cmdset_storage_set(self, value): + """ + Setter. Allows for self.name = value. Stores as a comma-separated + string. + """ + _SA(self, "db_cmdset_storage", ",".join(str(val).strip() for val in make_iter(value))) + _GA(self, "save")() + + #@cmdset_storage.deleter + def __cmdset_storage_del(self): + "Deleter. Allows for del self.name" + _SA(self, "db_cmdset_storage", None) + _GA(self, "save")() + cmdset_storage = property(__cmdset_storage_get, __cmdset_storage_set, __cmdset_storage_del) + + # + # property/field access + # + + def __str__(self): + return smart_str("%s(account %s)" % (self.name, self.dbid)) + + def __unicode__(self): + return u"%s(account#%s)" % (self.name, self.dbid) + + #@property + def __username_get(self): + return self.username + + def __username_set(self, value): + self.username = value + self.save(update_fields=["username"]) + + def __username_del(self): + del self.username + + # aliases + name = property(__username_get, __username_set, __username_del) + key = property(__username_get, __username_set, __username_del) + + #@property + def __uid_get(self): + "Getter. Retrieves the user id" + return self.id + + def __uid_set(self, value): + raise Exception("User id cannot be set!") + + def __uid_del(self): + raise Exception("User id cannot be deleted!") + uid = property(__uid_get, __uid_set, __uid_del)