##// END OF EJS Templates
fixed add cache defaults missing data_dir
marcink -
r1032:2e9f2bd2 beta
parent child Browse files
Show More
@@ -1,682 +1,685
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 rhodecode.lib.utils
4 4 ~~~~~~~~~~~~~~~~~~~
5 5
6 6 Utilities library for RhodeCode
7 7
8 8 :created_on: Apr 18, 2010
9 9 :author: marcink
10 10 :copyright: (C) 2009-2011 Marcin Kuzminski <marcin@python-works.com>
11 11 :license: GPLv3, see COPYING for more details.
12 12 """
13 13 # This program is free software; you can redistribute it and/or
14 14 # modify it under the terms of the GNU General Public License
15 15 # as published by the Free Software Foundation; version 2
16 16 # of the License or (at your opinion) any later version of the license.
17 17 #
18 18 # This program is distributed in the hope that it will be useful,
19 19 # but WITHOUT ANY WARRANTY; without even the implied warranty of
20 20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 21 # GNU General Public License for more details.
22 22 #
23 23 # You should have received a copy of the GNU General Public License
24 24 # along with this program; if not, write to the Free Software
25 25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
26 26 # MA 02110-1301, USA.
27 27
28 28 import os
29 29 import logging
30 30 import datetime
31 31 import traceback
32 32 import paste
33 33 import beaker
34 34
35 35 from paste.script.command import Command, BadCommand
36 36
37 37 from UserDict import DictMixin
38 38
39 39 from mercurial import ui, config, hg
40 40 from mercurial.error import RepoError
41 41
42 42 from webhelpers.text import collapse, remove_formatting, strip_tags
43 43
44 44 from vcs.backends.base import BaseChangeset
45 45 from vcs.utils.lazy import LazyProperty
46 46
47 47 from rhodecode.model import meta
48 48 from rhodecode.model.caching_query import FromCache
49 49 from rhodecode.model.db import Repository, User, RhodeCodeUi, UserLog, Group
50 50 from rhodecode.model.repo import RepoModel
51 51 from rhodecode.model.user import UserModel
52 52
53 53 log = logging.getLogger(__name__)
54 54
55 55
56 56 def recursive_replace(str, replace=' '):
57 57 """Recursive replace of given sign to just one instance
58 58
59 59 :param str: given string
60 60 :param replace: char to find and replace multiple instances
61 61
62 62 Examples::
63 63 >>> recursive_replace("Mighty---Mighty-Bo--sstones",'-')
64 64 'Mighty-Mighty-Bo-sstones'
65 65 """
66 66
67 67 if str.find(replace * 2) == -1:
68 68 return str
69 69 else:
70 70 str = str.replace(replace * 2, replace)
71 71 return recursive_replace(str, replace)
72 72
73 73 def repo_name_slug(value):
74 74 """Return slug of name of repository
75 75 This function is called on each creation/modification
76 76 of repository to prevent bad names in repo
77 77 """
78 78
79 79 slug = remove_formatting(value)
80 80 slug = strip_tags(slug)
81 81
82 82 for c in """=[]\;'"<>,/~!@#$%^&*()+{}|: """:
83 83 slug = slug.replace(c, '-')
84 84 slug = recursive_replace(slug, '-')
85 85 slug = collapse(slug, '-')
86 86 return slug
87 87
88 88 def get_repo_slug(request):
89 89 return request.environ['pylons.routes_dict'].get('repo_name')
90 90
91 91 def action_logger(user, action, repo, ipaddr='', sa=None):
92 92 """
93 93 Action logger for various actions made by users
94 94
95 95 :param user: user that made this action, can be a unique username string or
96 96 object containing user_id attribute
97 97 :param action: action to log, should be on of predefined unique actions for
98 98 easy translations
99 99 :param repo: string name of repository or object containing repo_id,
100 100 that action was made on
101 101 :param ipaddr: optional ip address from what the action was made
102 102 :param sa: optional sqlalchemy session
103 103
104 104 """
105 105
106 106 if not sa:
107 107 sa = meta.Session()
108 108
109 109 try:
110 110 um = UserModel()
111 111 if hasattr(user, 'user_id'):
112 112 user_obj = user
113 113 elif isinstance(user, basestring):
114 114 user_obj = um.get_by_username(user, cache=False)
115 115 else:
116 116 raise Exception('You have to provide user object or username')
117 117
118 118
119 119 rm = RepoModel()
120 120 if hasattr(repo, 'repo_id'):
121 121 repo_obj = rm.get(repo.repo_id, cache=False)
122 122 repo_name = repo_obj.repo_name
123 123 elif isinstance(repo, basestring):
124 124 repo_name = repo.lstrip('/')
125 125 repo_obj = rm.get_by_repo_name(repo_name, cache=False)
126 126 else:
127 127 raise Exception('You have to provide repository to action logger')
128 128
129 129
130 130 user_log = UserLog()
131 131 user_log.user_id = user_obj.user_id
132 132 user_log.action = action
133 133
134 134 user_log.repository_id = repo_obj.repo_id
135 135 user_log.repository_name = repo_name
136 136
137 137 user_log.action_date = datetime.datetime.now()
138 138 user_log.user_ip = ipaddr
139 139 sa.add(user_log)
140 140 sa.commit()
141 141
142 142 log.info('Adding user %s, action %s on %s', user_obj, action, repo)
143 143 except:
144 144 log.error(traceback.format_exc())
145 145 sa.rollback()
146 146
147 147 def get_repos(path, recursive=False):
148 148 """
149 149 Scans given path for repos and return (name,(type,path)) tuple
150 150
151 151 :param path: path to scann for repositories
152 152 :param recursive: recursive search and return names with subdirs in front
153 153 """
154 154 from vcs.utils.helpers import get_scm
155 155 from vcs.exceptions import VCSError
156 156
157 157 if path.endswith('/'):
158 158 #add ending slash for better results
159 159 path = path[:-1]
160 160
161 161 def _get_repos(p):
162 162 for dirpath in os.listdir(p):
163 163 if os.path.isfile(os.path.join(p, dirpath)):
164 164 continue
165 165 cur_path = os.path.join(p, dirpath)
166 166 try:
167 167 scm_info = get_scm(cur_path)
168 168 yield scm_info[1].split(path)[-1].lstrip('/'), scm_info
169 169 except VCSError:
170 170 if not recursive:
171 171 continue
172 172 #check if this dir containts other repos for recursive scan
173 173 rec_path = os.path.join(p, dirpath)
174 174 if os.path.isdir(rec_path):
175 175 for inner_scm in _get_repos(rec_path):
176 176 yield inner_scm
177 177
178 178 return _get_repos(path)
179 179
180 180 def check_repo_fast(repo_name, base_path):
181 181 """
182 182 Check given path for existence of directory
183 183 :param repo_name:
184 184 :param base_path:
185 185
186 186 :return False: if this directory is present
187 187 """
188 188 if os.path.isdir(os.path.join(base_path, repo_name)):return False
189 189 return True
190 190
191 191 def check_repo(repo_name, base_path, verify=True):
192 192
193 193 repo_path = os.path.join(base_path, repo_name)
194 194
195 195 try:
196 196 if not check_repo_fast(repo_name, base_path):
197 197 return False
198 198 r = hg.repository(ui.ui(), repo_path)
199 199 if verify:
200 200 hg.verify(r)
201 201 #here we hnow that repo exists it was verified
202 202 log.info('%s repo is already created', repo_name)
203 203 return False
204 204 except RepoError:
205 205 #it means that there is no valid repo there...
206 206 log.info('%s repo is free for creation', repo_name)
207 207 return True
208 208
209 209 def ask_ok(prompt, retries=4, complaint='Yes or no, please!'):
210 210 while True:
211 211 ok = raw_input(prompt)
212 212 if ok in ('y', 'ye', 'yes'): return True
213 213 if ok in ('n', 'no', 'nop', 'nope'): return False
214 214 retries = retries - 1
215 215 if retries < 0: raise IOError
216 216 print complaint
217 217
218 218 #propagated from mercurial documentation
219 219 ui_sections = ['alias', 'auth',
220 220 'decode/encode', 'defaults',
221 221 'diff', 'email',
222 222 'extensions', 'format',
223 223 'merge-patterns', 'merge-tools',
224 224 'hooks', 'http_proxy',
225 225 'smtp', 'patch',
226 226 'paths', 'profiling',
227 227 'server', 'trusted',
228 228 'ui', 'web', ]
229 229
230 230 def make_ui(read_from='file', path=None, checkpaths=True):
231 231 """A function that will read python rc files or database
232 232 and make an mercurial ui object from read options
233 233
234 234 :param path: path to mercurial config file
235 235 :param checkpaths: check the path
236 236 :param read_from: read from 'file' or 'db'
237 237 """
238 238
239 239 baseui = ui.ui()
240 240
241 241 #clean the baseui object
242 242 baseui._ocfg = config.config()
243 243 baseui._ucfg = config.config()
244 244 baseui._tcfg = config.config()
245 245
246 246 if read_from == 'file':
247 247 if not os.path.isfile(path):
248 248 log.warning('Unable to read config file %s' % path)
249 249 return False
250 250 log.debug('reading hgrc from %s', path)
251 251 cfg = config.config()
252 252 cfg.read(path)
253 253 for section in ui_sections:
254 254 for k, v in cfg.items(section):
255 255 log.debug('settings ui from file[%s]%s:%s', section, k, v)
256 256 baseui.setconfig(section, k, v)
257 257
258 258
259 259 elif read_from == 'db':
260 260 sa = meta.Session()
261 261 ret = sa.query(RhodeCodeUi)\
262 262 .options(FromCache("sql_cache_short",
263 263 "get_hg_ui_settings")).all()
264 264
265 265 hg_ui = ret
266 266 for ui_ in hg_ui:
267 267 if ui_.ui_active:
268 268 log.debug('settings ui from db[%s]%s:%s', ui_.ui_section,
269 269 ui_.ui_key, ui_.ui_value)
270 270 baseui.setconfig(ui_.ui_section, ui_.ui_key, ui_.ui_value)
271 271
272 272 meta.Session.remove()
273 273 return baseui
274 274
275 275
276 276 def set_rhodecode_config(config):
277 277 """Updates pylons config with new settings from database
278 278
279 279 :param config:
280 280 """
281 281 from rhodecode.model.settings import SettingsModel
282 282 hgsettings = SettingsModel().get_app_settings()
283 283
284 284 for k, v in hgsettings.items():
285 285 config[k] = v
286 286
287 287 def invalidate_cache(cache_key, *args):
288 288 """Puts cache invalidation task into db for
289 289 further global cache invalidation
290 290 """
291 291
292 292 from rhodecode.model.scm import ScmModel
293 293
294 294 if cache_key.startswith('get_repo_cached_'):
295 295 name = cache_key.split('get_repo_cached_')[-1]
296 296 ScmModel().mark_for_invalidation(name)
297 297
298 298 class EmptyChangeset(BaseChangeset):
299 299 """
300 300 An dummy empty changeset. It's possible to pass hash when creating
301 301 an EmptyChangeset
302 302 """
303 303
304 304 def __init__(self, cs='0' * 40):
305 305 self._empty_cs = cs
306 306 self.revision = -1
307 307 self.message = ''
308 308 self.author = ''
309 309 self.date = ''
310 310
311 311 @LazyProperty
312 312 def raw_id(self):
313 313 """Returns raw string identifying this changeset, useful for web
314 314 representation.
315 315 """
316 316
317 317 return self._empty_cs
318 318
319 319 @LazyProperty
320 320 def short_id(self):
321 321 return self.raw_id[:12]
322 322
323 323 def get_file_changeset(self, path):
324 324 return self
325 325
326 326 def get_file_content(self, path):
327 327 return u''
328 328
329 329 def get_file_size(self, path):
330 330 return 0
331 331
332 332 def map_groups(groups):
333 333 """Checks for groups existence, and creates groups structures.
334 334 It returns last group in structure
335 335
336 336 :param groups: list of groups structure
337 337 """
338 338 sa = meta.Session()
339 339
340 340 parent = None
341 341 group = None
342 342 for lvl, group_name in enumerate(groups[:-1]):
343 343 group = sa.query(Group).filter(Group.group_name == group_name).scalar()
344 344
345 345 if group is None:
346 346 group = Group(group_name, parent)
347 347 sa.add(group)
348 348 sa.commit()
349 349
350 350 parent = group
351 351
352 352 return group
353 353
354 354 def repo2db_mapper(initial_repo_list, remove_obsolete=False):
355 355 """maps all repos given in initial_repo_list, non existing repositories
356 356 are created, if remove_obsolete is True it also check for db entries
357 357 that are not in initial_repo_list and removes them.
358 358
359 359 :param initial_repo_list: list of repositories found by scanning methods
360 360 :param remove_obsolete: check for obsolete entries in database
361 361 """
362 362
363 363 sa = meta.Session()
364 364 rm = RepoModel()
365 365 user = sa.query(User).filter(User.admin == True).first()
366 366
367 367 for name, repo in initial_repo_list.items():
368 368 group = map_groups(name.split('/'))
369 369 if not rm.get_by_repo_name(name, cache=False):
370 370 log.info('repository %s not found creating default', name)
371 371
372 372 form_data = {
373 373 'repo_name':name,
374 374 'repo_type':repo.alias,
375 375 'description':repo.description \
376 376 if repo.description != 'unknown' else \
377 377 '%s repository' % name,
378 378 'private':False,
379 379 'group_id':getattr(group, 'group_id', None)
380 380 }
381 381 rm.create(form_data, user, just_db=True)
382 382
383 383 if remove_obsolete:
384 384 #remove from database those repositories that are not in the filesystem
385 385 for repo in sa.query(Repository).all():
386 386 if repo.repo_name not in initial_repo_list.keys():
387 387 sa.delete(repo)
388 388 sa.commit()
389 389
390 390 class OrderedDict(dict, DictMixin):
391 391
392 392 def __init__(self, *args, **kwds):
393 393 if len(args) > 1:
394 394 raise TypeError('expected at most 1 arguments, got %d' % len(args))
395 395 try:
396 396 self.__end
397 397 except AttributeError:
398 398 self.clear()
399 399 self.update(*args, **kwds)
400 400
401 401 def clear(self):
402 402 self.__end = end = []
403 403 end += [None, end, end] # sentinel node for doubly linked list
404 404 self.__map = {} # key --> [key, prev, next]
405 405 dict.clear(self)
406 406
407 407 def __setitem__(self, key, value):
408 408 if key not in self:
409 409 end = self.__end
410 410 curr = end[1]
411 411 curr[2] = end[1] = self.__map[key] = [key, curr, end]
412 412 dict.__setitem__(self, key, value)
413 413
414 414 def __delitem__(self, key):
415 415 dict.__delitem__(self, key)
416 416 key, prev, next = self.__map.pop(key)
417 417 prev[2] = next
418 418 next[1] = prev
419 419
420 420 def __iter__(self):
421 421 end = self.__end
422 422 curr = end[2]
423 423 while curr is not end:
424 424 yield curr[0]
425 425 curr = curr[2]
426 426
427 427 def __reversed__(self):
428 428 end = self.__end
429 429 curr = end[1]
430 430 while curr is not end:
431 431 yield curr[0]
432 432 curr = curr[1]
433 433
434 434 def popitem(self, last=True):
435 435 if not self:
436 436 raise KeyError('dictionary is empty')
437 437 if last:
438 438 key = reversed(self).next()
439 439 else:
440 440 key = iter(self).next()
441 441 value = self.pop(key)
442 442 return key, value
443 443
444 444 def __reduce__(self):
445 445 items = [[k, self[k]] for k in self]
446 446 tmp = self.__map, self.__end
447 447 del self.__map, self.__end
448 448 inst_dict = vars(self).copy()
449 449 self.__map, self.__end = tmp
450 450 if inst_dict:
451 451 return (self.__class__, (items,), inst_dict)
452 452 return self.__class__, (items,)
453 453
454 454 def keys(self):
455 455 return list(self)
456 456
457 457 setdefault = DictMixin.setdefault
458 458 update = DictMixin.update
459 459 pop = DictMixin.pop
460 460 values = DictMixin.values
461 461 items = DictMixin.items
462 462 iterkeys = DictMixin.iterkeys
463 463 itervalues = DictMixin.itervalues
464 464 iteritems = DictMixin.iteritems
465 465
466 466 def __repr__(self):
467 467 if not self:
468 468 return '%s()' % (self.__class__.__name__,)
469 469 return '%s(%r)' % (self.__class__.__name__, self.items())
470 470
471 471 def copy(self):
472 472 return self.__class__(self)
473 473
474 474 @classmethod
475 475 def fromkeys(cls, iterable, value=None):
476 476 d = cls()
477 477 for key in iterable:
478 478 d[key] = value
479 479 return d
480 480
481 481 def __eq__(self, other):
482 482 if isinstance(other, OrderedDict):
483 483 return len(self) == len(other) and self.items() == other.items()
484 484 return dict.__eq__(self, other)
485 485
486 486 def __ne__(self, other):
487 487 return not self == other
488 488
489 489
490 490 #set cache regions for beaker so celery can utilise it
491 491 def add_cache(settings):
492 492 cache_settings = {'regions':None}
493 493 for key in settings.keys():
494 494 for prefix in ['beaker.cache.', 'cache.']:
495 495 if key.startswith(prefix):
496 496 name = key.split(prefix)[1].strip()
497 497 cache_settings[name] = settings[key].strip()
498 498 if cache_settings['regions']:
499 499 for region in cache_settings['regions'].split(','):
500 500 region = region.strip()
501 501 region_settings = {}
502 502 for key, value in cache_settings.items():
503 503 if key.startswith(region):
504 504 region_settings[key.split('.')[1]] = value
505 505 region_settings['expire'] = int(region_settings.get('expire',
506 506 60))
507 507 region_settings.setdefault('lock_dir',
508 508 cache_settings.get('lock_dir'))
509 region_settings.setdefault('data_dir',
510 cache_settings.get('data_dir'))
511
509 512 if 'type' not in region_settings:
510 513 region_settings['type'] = cache_settings.get('type',
511 514 'memory')
512 515 beaker.cache.cache_regions[region] = region_settings
513 516
514 517 def get_current_revision():
515 518 """Returns tuple of (number, id) from repository containing this package
516 519 or None if repository could not be found.
517 520 """
518 521
519 522 try:
520 523 from vcs import get_repo
521 524 from vcs.utils.helpers import get_scm
522 525 from vcs.exceptions import RepositoryError, VCSError
523 526 repopath = os.path.join(os.path.dirname(__file__), '..', '..')
524 527 scm = get_scm(repopath)[0]
525 528 repo = get_repo(path=repopath, alias=scm)
526 529 tip = repo.get_changeset()
527 530 return (tip.revision, tip.short_id)
528 531 except (ImportError, RepositoryError, VCSError), err:
529 532 logging.debug("Cannot retrieve rhodecode's revision. Original error "
530 533 "was: %s" % err)
531 534 return None
532 535
533 536 #===============================================================================
534 537 # TEST FUNCTIONS AND CREATORS
535 538 #===============================================================================
536 539 def create_test_index(repo_location, full_index):
537 540 """Makes default test index
538 541 :param repo_location:
539 542 :param full_index:
540 543 """
541 544 from rhodecode.lib.indexers.daemon import WhooshIndexingDaemon
542 545 from rhodecode.lib.pidlock import DaemonLock, LockHeld
543 546 import shutil
544 547
545 548 index_location = os.path.join(repo_location, 'index')
546 549 if os.path.exists(index_location):
547 550 shutil.rmtree(index_location)
548 551
549 552 try:
550 553 l = DaemonLock()
551 554 WhooshIndexingDaemon(index_location=index_location,
552 555 repo_location=repo_location)\
553 556 .run(full_index=full_index)
554 557 l.release()
555 558 except LockHeld:
556 559 pass
557 560
558 561 def create_test_env(repos_test_path, config):
559 562 """Makes a fresh database and
560 563 install test repository into tmp dir
561 564 """
562 565 from rhodecode.lib.db_manage import DbManage
563 566 from rhodecode.tests import HG_REPO, GIT_REPO, NEW_HG_REPO, NEW_GIT_REPO, \
564 567 HG_FORK, GIT_FORK, TESTS_TMP_PATH
565 568 import tarfile
566 569 import shutil
567 570 from os.path import dirname as dn, join as jn, abspath
568 571
569 572 log = logging.getLogger('TestEnvCreator')
570 573 # create logger
571 574 log.setLevel(logging.DEBUG)
572 575 log.propagate = True
573 576 # create console handler and set level to debug
574 577 ch = logging.StreamHandler()
575 578 ch.setLevel(logging.DEBUG)
576 579
577 580 # create formatter
578 581 formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
579 582
580 583 # add formatter to ch
581 584 ch.setFormatter(formatter)
582 585
583 586 # add ch to logger
584 587 log.addHandler(ch)
585 588
586 589 #PART ONE create db
587 590 dbconf = config['sqlalchemy.db1.url']
588 591 log.debug('making test db %s', dbconf)
589 592
590 593 dbmanage = DbManage(log_sql=True, dbconf=dbconf, root=config['here'],
591 594 tests=True)
592 595 dbmanage.create_tables(override=True)
593 596 dbmanage.config_prompt(repos_test_path)
594 597 dbmanage.create_default_user()
595 598 dbmanage.admin_prompt()
596 599 dbmanage.create_permissions()
597 600 dbmanage.populate_default_permissions()
598 601
599 602 #PART TWO make test repo
600 603 log.debug('making test vcs repositories')
601 604
602 605 #remove old one from previos tests
603 606 for r in [HG_REPO, GIT_REPO, NEW_HG_REPO, NEW_GIT_REPO, HG_FORK, GIT_FORK]:
604 607
605 608 if os.path.isdir(jn(TESTS_TMP_PATH, r)):
606 609 log.debug('removing %s', r)
607 610 shutil.rmtree(jn(TESTS_TMP_PATH, r))
608 611
609 612 #CREATE DEFAULT HG REPOSITORY
610 613 cur_dir = dn(dn(abspath(__file__)))
611 614 tar = tarfile.open(jn(cur_dir, 'tests', "vcs_test_hg.tar.gz"))
612 615 tar.extractall(jn(TESTS_TMP_PATH, HG_REPO))
613 616 tar.close()
614 617
615 618
616 619 #==============================================================================
617 620 # PASTER COMMANDS
618 621 #==============================================================================
619 622
620 623 class BasePasterCommand(Command):
621 624 """
622 625 Abstract Base Class for paster commands.
623 626
624 627 The celery commands are somewhat aggressive about loading
625 628 celery.conf, and since our module sets the `CELERY_LOADER`
626 629 environment variable to our loader, we have to bootstrap a bit and
627 630 make sure we've had a chance to load the pylons config off of the
628 631 command line, otherwise everything fails.
629 632 """
630 633 min_args = 1
631 634 min_args_error = "Please provide a paster config file as an argument."
632 635 takes_config_file = 1
633 636 requires_config_file = True
634 637
635 638 def notify_msg(self, msg, log=False):
636 639 """Make a notification to user, additionally if logger is passed
637 640 it logs this action using given logger
638 641
639 642 :param msg: message that will be printed to user
640 643 :param log: logging instance, to use to additionally log this message
641 644
642 645 """
643 646 if log and isinstance(log, logging):
644 647 log(msg)
645 648
646 649
647 650 def run(self, args):
648 651 """
649 652 Overrides Command.run
650 653
651 654 Checks for a config file argument and loads it.
652 655 """
653 656 if len(args) < self.min_args:
654 657 raise BadCommand(
655 658 self.min_args_error % {'min_args': self.min_args,
656 659 'actual_args': len(args)})
657 660
658 661 # Decrement because we're going to lob off the first argument.
659 662 # @@ This is hacky
660 663 self.min_args -= 1
661 664 self.bootstrap_config(args[0])
662 665 self.update_parser()
663 666 return super(BasePasterCommand, self).run(args[1:])
664 667
665 668 def update_parser(self):
666 669 """
667 670 Abstract method. Allows for the class's parser to be updated
668 671 before the superclass's `run` method is called. Necessary to
669 672 allow options/arguments to be passed through to the underlying
670 673 celery command.
671 674 """
672 675 raise NotImplementedError("Abstract Method.")
673 676
674 677 def bootstrap_config(self, conf):
675 678 """
676 679 Loads the pylons configuration.
677 680 """
678 681 from pylons import config as pylonsconfig
679 682
680 683 path_to_ini_file = os.path.realpath(conf)
681 684 conf = paste.deploy.appconfig('config:' + path_to_ini_file)
682 685 pylonsconfig.init_app(conf.global_conf, conf.local_conf)
General Comments 0
You need to be logged in to leave comments. Login now