Initial commit of foodsoft 2

This commit is contained in:
Benjamin Meichsner 2009-01-06 11:49:19 +01:00
commit 5b9a7e05df
657 changed files with 70444 additions and 0 deletions

201
app/models/article.rb Normal file
View file

@ -0,0 +1,201 @@
# articles are the internal products which can ordered by ordergroups
#
# articles have the following attributes:
# * name
# * supplier_id
# * article_category_id
# * unit (string, e.g. 500gr, 1liter)
# * note
# * availability (boolean)
# * net_price, decimal (net price, which will be edited by the user)
# * gross_price, decimal (gross price (or long price), incl. tax, deposit, price markup ... see environment.rb)
# * tax, float (the VAT, value added tax. default is 7.00 which means 7.00%)
# * deposit, decimal (deposit, e.g. for bottles)
# * unit_quantity, int (the internal(FC) size of trading unit)
# * order_number, varchar (for the supplier)
# * created_at, timestamp
# * updated_at, timestamp
#
class Article < ActiveRecord::Base
belongs_to :supplier
belongs_to :article_category
validates_presence_of :name, :unit, :net_price, :tax, :deposit, :unit_quantity, :supplier_id
validates_length_of :name, :in => 4..60
validates_length_of :unit, :in => 2..15
validates_numericality_of :net_price, :greater_than => 0
validates_numericality_of :deposit, :tax
# calculate the gross_price
before_save :calc_gross_price
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def net_price=(net_price)
self[:net_price] = FoodSoft::delocalizeDecimalString(net_price)
end
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def tax=(tax)
self[:tax] = FoodSoft::delocalizeDecimalString(tax)
end
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def deposit=(deposit)
self[:deposit] = FoodSoft::delocalizeDecimalString(deposit)
end
# calculate the fc price and sets the attribute
def calc_gross_price
self.gross_price = ((net_price + deposit) * (tax / 100 + 1)) * (FoodSoft::getPriceMarkup / 100 + 1)
end
# Returns true if article has been updated at least 2 days ago
def recently_updated
updated_at > 2.days.ago
end
# Returns how many units of this article need to be ordered given the specified order quantity and tolerance.
# This is determined by calculating how many units can be ordered from the given order quantity, using
# the tolerance to order an additional unit if the order quantity is not quiet sufficient.
# There must always be at least one item in a unit that is an ordered quantity (no units are ever entirely
# filled by tolerance items only).
#
# Example:
#
# unit_quantity | quantity | tolerance | calculateOrderQuantity
# --------------+----------+-----------+-----------------------
# 4 | 0 | 2 | 0
# 4 | 0 | 5 | 0
# 4 | 2 | 2 | 1
# 4 | 4 | 2 | 1
# 4 | 4 | 4 | 1
# 4 | 5 | 3 | 2
# 4 | 5 | 4 | 2
#
def calculateOrderQuantity(quantity, tolerance = 0)
unitSize = unit_quantity
units = quantity / unitSize
remainder = quantity % unitSize
units += ((remainder > 0) && (remainder + tolerance >= unitSize) ? 1 : 0)
end
# If the article is used in an active Order, the Order will returned.
def inUse
Order.find(:all, :conditions => 'finished = 0').each do |order|
if order.articles.find_by_id(self)
@order = order
break
end
end
return @order if @order
end
# Checks if the article is in use before it will deleted
def before_destroy
raise self.name.to_s + _(" cannot be deleted. The article is used in a current order!") if self.inUse
end
# this method checks, if the shared_article has been changed
# unequal attributes will returned in array
# if only the timestamps differ and the attributes are equal,
# false will returned and self.shared_updated_on will be updated
def shared_article_changed?
# skip early if the timestamp hasn't changed
unless self.shared_updated_on == self.shared_article.updated_on
# try to convert units
# convert supplier's price and unit_quantity into fc-size
new_price, new_unit_quantity = self.convert_units
new_unit = self.unit
unless new_price and new_unit_quantity
# if convertion isn't possible, take shared_article-price/unit_quantity
new_price, new_unit_quantity, new_unit = self.shared_article.price, self.shared_article.unit_quantity, self.shared_article.unit
end
# check if all attributes differ
unequal_attributes = Article.compare_attributes(
{
:name => [self.name, self.shared_article.name],
:manufacturer => [self.manufacturer, self.shared_article.manufacturer.to_s],
:origin => [self.origin, self.shared_article.origin],
:unit => [self.unit, new_unit],
:net_price => [self.net_price, new_price],
:tax => [self.tax, self.shared_article.tax],
:deposit => [self.deposit, self.shared_article.deposit],
# take care of different num-objects.
:unit_quantity => [self.unit_quantity.to_s.to_f, new_unit_quantity.to_s.to_f],
:note => [self.note.to_s, self.shared_article.note.to_s]
}
)
if unequal_attributes.empty?
# when attributes not changed, update timestamp of article
self.update_attribute(:shared_updated_on, self.shared_article.updated_on)
false
else
unequal_attributes
end
end
end
# compare attributes from different articles. used for auto-synchronization
# returns array of symbolized unequal attributes
def self.compare_attributes(attributes)
unequal_attributes = attributes.select { |name, values| values[0] != values[1] }
unequal_attributes.collect { |pair| pair[0] }
end
# to get the correspondent shared article
def shared_article
@shared_article ||= self.supplier.shared_supplier.shared_articles.find_by_number(self.order_number)
end
# convert units in foodcoop-size
# uses FoodSoft.get_units_factors to calc the price/unit_quantity
# returns new price and unit_quantity in array, when calc is possible => [price, unit_quanity]
# returns false if units aren't foodsoft-compatible
# returns nil if units are eqal
def convert_units
if unit != shared_article.unit
if shared_article.unit == "KI" and unit == "ST" # 'KI' means a box, with a different amount of items in it
# try to match the size out of its name, e.g. "banana 10-12 St" => 10
new_unit_quantity = /[0-9\-\s]+(St)/.match(shared_article.name).to_s.to_i
if new_unit_quantity and new_unit_quantity > 0
new_price = (shared_article.price/new_unit_quantity.to_f).round(2)
[new_price, new_unit_quantity]
else
false
end
else # get factors for fc and supplier
fc_unit_factor, supplier_unit_factor = FoodSoft.get_units_factors[self.unit], FoodSoft.get_units_factors[self.shared_article.unit]
if fc_unit_factor and supplier_unit_factor
convertion_factor = fc_unit_factor / supplier_unit_factor
new_price = BigDecimal((convertion_factor * shared_article.price).to_s).round(2)
new_unit_quantity = ( 1 / convertion_factor) * shared_article.unit_quantity
[new_price, new_unit_quantity]
else
false
end
end
else
nil
end
end
# Returns Articles in a nested Array, grouped by category and ordered by article name.
# The array has the following form:
# e.g: [["drugs",[teethpaste, toiletpaper]], ["fruits" => [apple, banana, lemon]]]
# TODO: force article to belong to a category and remove this complicated implementation!
def self.group_by_category(articles)
articles_by_category = {}
ArticleCategory.find(:all).each do |category|
articles_by_category.merge!(category.name.to_s => articles.select {|article| article.article_category and article.article_category.id == category.id })
end
# add articles without a category
articles_by_category.merge!( "--" => articles.select {|article| article.article_category == nil})
# return "clean" hash, sorted by category.name
return articles_by_category.reject {|category, array| array.empty?}.sort
# it could be so easy ... but that doesn't work for empty category-ids...
# articles.group_by {|a| a.article_category}.sort {|a, b| a[0].name <=> b[0].name}
end
end

View file

@ -0,0 +1,7 @@
class ArticleCategory < ActiveRecord::Base
has_many :articles
validates_length_of :name, :in => 2..20
validates_uniqueness_of :name
end

17
app/models/assignment.rb Normal file
View file

@ -0,0 +1,17 @@
class Assignment < ActiveRecord::Base
# gettext-option
untranslate_all
belongs_to :user
belongs_to :task
# after user is assigned mark task as assigned
def after_create
self.task.update_attribute(:assigned, true)
end
# update assigned-attribute
def after_destroy
self.task.update_attribute(:assigned, false) if self.task.assignments.empty?
end
end

View file

@ -0,0 +1,21 @@
# financial transactions are the foodcoop internal financial transactions
# only order_groups have an account balance and are happy to transfer money
#
# financial transaction have the following attributes:
# * order_group_id (int)
# * amount (decimal)
# * note (text)
# * created_on (datetime)
class FinancialTransaction < ActiveRecord::Base
belongs_to :order_group
belongs_to :user
validates_presence_of :note, :user_id, :order_group_id
validates_numericality_of :amount
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def amount=(amount)
self[:amount] = FoodSoft::delocalizeDecimalString(amount)
end
end

90
app/models/group.rb Normal file
View file

@ -0,0 +1,90 @@
# Groups organize the User.
#
# Group have the following attributes
# * name
# * description
# * type (to specify, if it is a OrderGroup)
# * role_admin, role_suppliers, role_article_eta, role_finance, role_orders
# * weekly_task (if the group should do a job ervery week)
# * weekday (on which weekday should the job be done? 1 means monday and so on)
#
# A Member gets the roles from the Group
class Group < ActiveRecord::Base
has_many :memberships, :dependent => :destroy
has_many :users, :through => :memberships
has_many :tasks
# returns all non-finished tasks
has_many :open_tasks, :class_name => 'Task', :conditions => ['done = ?', false], :order => 'due_date ASC'
attr_accessible :name, :description, :role_admin, :role_suppliers, :role_article_meta, :role_finance, :role_orders,
:weekly_task, :weekday, :task_name, :task_description, :task_required_users
validates_length_of :name, :in => 1..25
validates_uniqueness_of :name
# messages
ERR_LAST_ADMIN_GROUP_UPDATE = "Der letzten Gruppe mit Admin-Rechten darf die Admin-Rolle nicht entzogen werden"
ERR_LAST_ADMIN_GROUP_DELETE = "Die letzte Gruppe mit Admin-Rechten darf nicht gelöscht werden"
# Returns true if the given user if is an member of this group.
def member?(user)
memberships.find_by_user_id(user.id)
end
# Returns all NONmembers and a checks for possible multiple OrderGroup-Memberships
def non_members
nonMembers = Array.new
for user in User.find(:all, :order => "nick")
unless self.users.include?(user) || ( self.is_a?(OrderGroup) && user.find_ordergroup )
nonMembers << user
end
end
return nonMembers
end
# Check before destroy a group, if this is the last group with admin role
def before_destroy
raise ERR_LAST_ADMIN_GROUP_DELETE if self.role_admin == true && Group.find_all_by_role_admin(true).size == 1
end
# Returns an Array with date-objects to represent the next weekly-tasks
def next_weekly_tasks(number = 8)
# our system starts from 0 (sunday) to 6 (saturday)
# get difference between groups weekday and now
diff = self.weekday - Time.now.wday
if diff >= 0
# weektask is in current week
nextTask = diff.day.from_now
else
# weektask is in the next week
nextTask = (diff + 7).day.from_now
end
# now generate the Array
nextTasks = Array.new
number.times do
nextTasks << nextTask
nextTask = 1.week.from_now(nextTask)
end
return nextTasks
end
# get all groups, which are NOT OrderGroups
#TODO: better implement a new model, which inherits from Group, e.g. WorkGroup
def self.workgroups
Group.find :all, :conditions => "type IS NULL"
end
protected
# validates uniqueness of the Group.name. Checks groups and order_groups
def validate
errors.add(:name, "ist schon vergeben") if (group = Group.find_by_name(name) || group = OrderGroup.find_by_name(name)) && self != group
end
# add validation check on update
def validate_on_update
# error if this is the last group with admin role and role_admin should set to false
errors.add(:role_admin, ERR_LAST_ADMIN_GROUP_UPDATE) if self.role_admin == false && Group.find_all_by_role_admin(true).size == 1 && self == Group.find(:first, :conditions => "role_admin = 1")
end
end

39
app/models/group_order.rb Normal file
View file

@ -0,0 +1,39 @@
# A GroupOrder represents an Order placed by an OrderGroup.
#
# Properties:
# * order_id (int): association to the Order
# * order_group_id (int): association to the OrderGroup
# * group_order_articles: collection of associated GroupOrderArticles
# * order_articles: collection of associated OrderArticles (through GroupOrderArticles)
# * price (decimal): the price of this GroupOrder (either maximum price if current order or the actual price if finished order)
# * lock_version (int): ActiveRecord optimistic locking column
# * updated_by (User): the user who last updated this order
#
class GroupOrder < ActiveRecord::Base
# gettext-option
untranslate_all
belongs_to :order
belongs_to :order_group
has_many :group_order_articles, :dependent => :destroy
has_many :order_articles, :through => :group_order_articles
has_many :group_order_article_results
belongs_to :updated_by, :class_name => "User", :foreign_key => "updated_by_user_id"
validates_presence_of :order_id
validates_presence_of :order_group_id
validates_presence_of :updated_by
validates_numericality_of :price
validates_uniqueness_of :order_group_id, :scope => :order_id # order groups can only order once per order
# Updates the "price" attribute.
# This will be the maximum value of a current order
def updatePrice
total = 0
for article in group_order_articles.find(:all, :include => :order_article)
total += article.order_article.article.gross_price * (article.quantity + article.tolerance)
end
self.price = total
end
end

View file

@ -0,0 +1,146 @@
# A GroupOrderArticle stores the sum of how many items of an OrderArticle are ordered as part of a GroupOrder.
# The chronologically order of the OrderGroup - activity are stored in GroupOrderArticleQuantity
#
# Properties:
# * group_order_id (int): association to the GroupOrder
# * order_article_id (int): association to the OrderArticle
# * quantity (int): number of items ordered
# * tolerance (int): number of items ordered as tolerance
# * updated_on (timestamp): updated automatically by ActiveRecord
#
class GroupOrderArticle < ActiveRecord::Base
# gettext-option
untranslate_all
belongs_to :group_order
belongs_to :order_article
has_many :group_order_article_quantities, :dependent => :destroy
validates_presence_of :group_order_id
validates_presence_of :order_article_id
validates_inclusion_of :quantity, :in => 0..99
validates_inclusion_of :tolerance, :in => 0..99
validates_uniqueness_of :order_article_id, :scope => :group_order_id # just once an article per group order
# Updates the quantity/tolerance for this GroupOrderArticle by updating both GroupOrderArticle properties
# and the associated GroupOrderArticleQuantities chronologically.
#
# See description of the ordering algorithm in the general application documentation for details.
def updateQuantities(quantity, tolerance)
logger.debug("GroupOrderArticle[#{id}].updateQuantities(#{quantity}, #{tolerance})")
logger.debug("Current quantity = #{self.quantity}, tolerance = #{self.tolerance}")
# Get quantities ordered with the newest item first.
quantities = group_order_article_quantities.find(:all, :order => 'created_on desc')
logger.debug("GroupOrderArticleQuantity items found: #{quantities.size}")
if (quantities.size == 0)
# There is no GroupOrderArticleQuantity item yet, just insert with desired quantities...
logger.debug("No quantities entry at all, inserting a new one with the desired quantities")
quantities.push(GroupOrderArticleQuantity.new(:group_order_article => self, :quantity => quantity, :tolerance => tolerance))
self.quantity, self.tolerance = quantity, tolerance
else
# Decrease quantity/tolerance if necessary by going through the existing items and decreasing their values...
i = 0
while (i < quantities.size && (quantity < self.quantity || tolerance < self.tolerance))
logger.debug("Need to decrease quantities for GroupOrderArticleQuantity[#{quantities[i].id}]")
if (quantity < self.quantity && quantities[i].quantity > 0)
delta = self.quantity - quantity
delta = (delta > quantities[i].quantity ? quantities[i].quantity : delta)
logger.debug("Decreasing quantity by #{delta}")
quantities[i].quantity -= delta
self.quantity -= delta
end
if (tolerance < self.tolerance && quantities[i].tolerance > 0)
delta = self.tolerance - tolerance
delta = (delta > quantities[i].tolerance ? quantities[i].tolerance : delta)
logger.debug("Decreasing tolerance by #{delta}")
quantities[i].tolerance -= delta
self.tolerance -= delta
end
i += 1
end
# If there is at least one increased value: insert a new GroupOrderArticleQuantity object
if (quantity > self.quantity || tolerance > self.tolerance)
logger.debug("Inserting a new GroupOrderArticleQuantity")
quantities.insert(0, GroupOrderArticleQuantity.new(
:group_order_article => self,
:quantity => (quantity > self.quantity ? quantity - self.quantity : 0),
:tolerance => (tolerance > self.tolerance ? tolerance - self.tolerance : 0)
))
# Recalc totals:
self.quantity += quantities[0].quantity
self.tolerance += quantities[0].tolerance
end
end
# Check if something went terribly wrong and quantites have not been adjusted as desired.
if (self.quantity != quantity || self.tolerance != tolerance)
raise 'Invalid state: unable to update GroupOrderArticle/-Quantities to desired quantities!'
end
# Remove zero-only items.
quantities = quantities.reject { | q | q.quantity == 0 && q.tolerance == 0}
# Save
transaction do
quantities.each { | i | i.save! }
self.group_order_article_quantities = quantities
save!
end
end
# Determines how many items of this article the OrderGroup receives.
# Returns a hash with three keys: :quantity / :tolerance / :total
#
# See description of the ordering algorithm in the general application documentation for details.
def orderResult
quantity = tolerance = 0
# Get total
total = order_article.units_to_order * order_article.article.unit_quantity
logger.debug("unitsToOrder => items ordered: #{order_article.units_to_order} => #{total}")
if (total > 0)
# Get all GroupOrderArticleQuantities for this OrderArticle...
orderArticles = GroupOrderArticle.find(:all, :conditions => ['order_article_id = ? AND group_order_id IN (?)', order_article.id, group_order.order.group_orders.collect { | o | o.id }])
orderQuantities = GroupOrderArticleQuantity.find(:all, :conditions => ['group_order_article_id IN (?)', orderArticles.collect { | i | i.id }], :order => 'created_on')
logger.debug("GroupOrderArticleQuantity records found: #{orderQuantities.size}")
# Determine quantities to be ordered...
totalQuantity = i = 0
while (i < orderQuantities.size && totalQuantity < total)
q = (orderQuantities[i].quantity <= total - totalQuantity ? orderQuantities[i].quantity : total - totalQuantity)
totalQuantity += q
if (orderQuantities[i].group_order_article_id == self.id)
logger.debug("increasing quantity by #{q}")
quantity += q
end
i += 1
end
# Determine tolerance to be ordered...
if (totalQuantity < total)
logger.debug("determining additional items to be ordered from tolerance")
i = 0
while (i < orderQuantities.size && totalQuantity < total)
q = (orderQuantities[i].tolerance <= total - totalQuantity ? orderQuantities[i].tolerance : total - totalQuantity)
totalQuantity += q
if (orderQuantities[i].group_order_article_id == self.id)
logger.debug("increasing tolerance by #{q}")
tolerance += q
end
i += 1
end
end
# calculate the sum of quantity and tolerance:
sum = quantity + tolerance
logger.debug("determined quantity/tolerance/total: #{quantity} / #{tolerance} / #{sum}")
end
{:quantity => quantity, :tolerance => tolerance, :total => sum}
end
end

View file

@ -0,0 +1,20 @@
# stores the quantity, tolerance and timestamp of an GroupOrderArticle
# Considers every update of an article-order, so may rows for one group_order_article ar possible.
#
# properties:
# * group_order_article_id (int)
# * quantity (int)
# * tolerance (in)
# * created_on (timestamp)
class GroupOrderArticleQuantity < ActiveRecord::Base
# gettext-option
untranslate_all
belongs_to :group_order_article
validates_presence_of :group_order_article_id
validates_inclusion_of :quantity, :in => 0..99
validates_inclusion_of :tolerance, :in => 0..99
end

View file

@ -0,0 +1,27 @@
# An GroupOrderArticleResult represents a group-order for a single Article and its quantities,
# according to the order quantity/tolerance.
# The GroupOrderArticleResult is part of a finished Order, see OrderArticleResult.
#
# Properties:
# * order_article_result_id (int)
# * group_order_result_id (int): associated with OrderGroup through GroupOrderResult.group_name
# * quantity (int)
#
class GroupOrderArticleResult < ActiveRecord::Base
belongs_to :order_article_result
belongs_to :group_order_result
validates_presence_of :order_article_result, :group_order_result, :quantity
validates_numericality_of :quantity, :minimum => 0
# updates the price attribute for the appropriate GroupOrderResult
after_update {|result| result.group_order_result.updatePrice }
after_destroy {|result| result.group_order_result.updatePrice }
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def quantity=(quantity)
self[:quantity] = FoodSoft::delocalizeDecimalString(quantity)
end
end

View file

@ -0,0 +1,23 @@
# OrderGroups, which participate on a specific order will have a line
# Properties:
# * order_id, int
# * group_name, the name of the group
# * price, decimal
# * group_order_article_results: collection of associated GroupOrderArticleResults
#
class GroupOrderResult < ActiveRecord::Base
# gettext-option
untranslate_all
belongs_to :order
has_many :group_order_article_results, :dependent => :destroy
# Calculates the Order-Price for the OrderGroup and updates the price-attribute
def updatePrice
total = 0
group_order_article_results.each do |result|
total += result.order_article_result.gross_price * result.quantity
end
update_attribute(:price, total)
end
end

46
app/models/invite.rb Normal file
View file

@ -0,0 +1,46 @@
require 'digest/sha1'
# Invites are created by foodcoop users to invite a new user into the foodcoop and their order group.
#
# Attributes:
# * token - the authentication token for this invite
# * group - the group the new user is to be made a member of
# * user - the inviting user
# * expires_at - the time this invite expires
# * email - the recipient's email address
class Invite < ActiveRecord::Base
belongs_to :user
belongs_to :group
validates_format_of :email, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :message => 'ist keine gültige Email-Adresse'
validates_presence_of :user
validates_presence_of :group
validates_presence_of :token
validates_presence_of :expires_at
attr_accessible :email, :user, :group
# messages
ERR_EMAIL_IN_USE = 'ist bereits in Verwendung'
protected
# Before validation, set token and expires_at.
def before_validation
self.token = Digest::SHA1.hexdigest(Time.now.to_s + rand(100).to_s)
self.expires_at = Time.now.advance(:days => 2)
end
# Sends an email to the invited user.
def after_create
Mailer.deliver_invite(self)
end
private
# Custom validation: check that email does not already belong to a registered user.
def validate_on_create
errors.add(:email, ERR_EMAIL_IN_USE) unless User.find_by_email(self.email).nil?
end
end

41
app/models/mailer.rb Normal file
View file

@ -0,0 +1,41 @@
# ActionMailer class that handles all emails for the FoodSoft.
class Mailer < ActionMailer::Base
# Sends an email with instructions on how to reset the password.
# Assumes user.setResetPasswordToken has been successfully called already.
def password(user)
request = ApplicationController.current.request
subject "[#{FoodSoft::getFoodcoopName}] Neues Passwort für/ New password for " + user.nick
recipients user.email
from "FoodSoft <#{FoodSoft::getEmailSender}>"
body :user => user,
:link => url_for(:host => FoodSoft::getHost || request.host, :controller => "login", :action => "password", :id => user.id, :token => user.reset_password_token),
:foodsoftUrl => url_for(:host => FoodSoft::getHost || request.host, :controller => "index")
end
# Sends an email copy of the given internal foodsoft message.
def message(message)
request = ApplicationController.current.request
subject "[#{FoodSoft::getFoodcoopName}] " + message.subject
recipients message.recipient.email
from (message.system_message? ? "FoodSoft <#{FoodSoft::getEmailSender}>" : "#{message.sender.nick} <#{message.sender.email}>")
body :body => message.body, :sender => (message.system_message? ? 'Foodsoft' : message.sender.nick),
:recipients => message.recipients,
:reply => url_for(:host => FoodSoft::getHost || request.host, :controller => "messages", :action => "reply", :id => message),
:profile => url_for(:host => FoodSoft::getHost || request.host, :controller => "index", :action => "myProfile", :id => message.recipient),
:link => url_for(:host => FoodSoft::getHost || request.host, :controller => "messages", :action => "show", :id => message),
:foodsoftUrl => url_for(:host => FoodSoft::getHost || request.host, :controller => "index")
end
# Sends an invite email.
def invite(invite)
request = ApplicationController.current.request
subject "Einladung in die Foodcoop #{FoodSoft::getFoodcoopName} - Invitation to the Foodcoop"
recipients invite.email
from "FoodSoft <#{FoodSoft::getEmailSender}>"
body :invite => invite,
:link => url_for(:host => FoodSoft::getHost || request.host, :controller => "login", :action => "invite", :id => invite.token),
:foodsoftUrl => url_for(:host => FoodSoft::getHost || request.host, :controller => "index")
end
end

16
app/models/membership.rb Normal file
View file

@ -0,0 +1,16 @@
class Membership < ActiveRecord::Base
# gettext-option
untranslate_all
belongs_to :user
belongs_to :group
# messages
ERR_NO_ADMIN_MEMBER_DELETE = "Mitgliedschaft kann nicht beendet werden. Du bist die letzte Administratorin"
# check if this is the last admin-membership and deny
def before_destroy
raise ERR_NO_ADMIN_MEMBER_DELETE if self.group.role_admin? && self.group.memberships.size == 1 && Group.find_all_by_role_admin(true).size == 1
end
end

101
app/models/message.rb Normal file
View file

@ -0,0 +1,101 @@
# A message within the foodsoft.
#
# * sender (User) - the sending User (might be nil if it is a system message)
# * recipient (User) - the receiving User
# * recipients (String) - list of all recipients of this message as User.nick/Group.name
# * subject (string) - message subject
# * body (string) - message body
# * read? (boolean) - message read status
# * email_state (integer) - email state, one of EMAIL_STATE.values
# * created_on (timestamp) - creation timestamp
class Message < ActiveRecord::Base
belongs_to :sender, :class_name => "User", :foreign_key => "sender_id"
belongs_to :recipient, :class_name => "User", :foreign_key => "recipient_id"
attr_accessible :recipient_id, :recipient, :subject, :body, :recipients
# needed for method 'from_template'
include GetText::Rails
# Values for the email_state attribute: :none, :pending, :sent, :failed
EMAIL_STATE = {
:none => 0,
:pending => 1,
:sent => 2,
:failed => 3
}
validates_presence_of :recipient_id
validates_length_of :subject, :in => 1..255
validates_presence_of :recipients
validates_presence_of :body
validates_inclusion_of :email_state, :in => EMAIL_STATE.values
@@pending = false
# Automatically determine if this message should be send as an email.
def before_validation_on_create
if (recipient && recipient.settings["messages.sendAsEmail"] == '1')
self.email_state = EMAIL_STATE[:pending]
else
self.email_state = EMAIL_STATE[:none]
end
end
# Determines if this new message is a pending email.
def after_create
@@pending = @@pending || self.email_state == EMAIL_STATE[:pending]
end
# Returns true if there might be pending emails.
def self.pending?
@@pending
end
# Returns true if this message is a system message, i.e. was sent automatically by the FoodSoft itself.
def system_message?
self.sender_id.nil?
end
# Sends all pending messages that are to be send as emails.
def self.send_emails
transaction do
messages = find(:all, :conditions => ["email_state = ?", EMAIL_STATE[:pending]], :lock => true)
logger.debug("Sending #{messages.size} pending messages as emails...") unless messages.empty?
for message in messages
if (message.recipient && message.recipient.email && !message.recipient.email.empty?)
begin
Mailer.deliver_message(message)
message.update_attribute(:email_state, EMAIL_STATE[:sent])
logger.debug("Delivered message as email: id = #{message.id}, recipient = #{message.recipient.nick}, subject = \"#{message.subject}\"")
rescue => exception
message.update_attribute(:email_state, EMAIL_STATE[:failed])
logger.warn("Failed to deliver message as email: id = #{message.id}, recipient = #{message.recipient.nick}, subject = \"#{message.subject}\", exception = #{exception.message}")
end
else
message.update_attribute(:email_state, EMAIL_STATE[:failed])
logger.warn("Cannot deliver message as email (no user email): id = #{message.id}, recipient = #{message.recipient.nick}, subject = \"#{message.subject}\"")
end
end
logger.debug("Done sending emails.") unless messages.empty?
@@pending = false
end
end
# Returns a new message object created from the attributes specified (recipient, recipients, subject)
# and the body from the given template that can make use of the variables specified.
# The templates are to be stored in app/views/messages, i.e. the template name
# "order_finished" would invoke template file "app/views/messages/order_finished.rhtml".
# Note: you need to set the sender afterwards if this should not be a system message.
#
# Example:
# Message.from_template(
# 'order_finished',
# {:user => user, :group => order_group, :order => self, :results => results, :total => group_order.price},
# {:recipient_id => user.id, :recipients => recipients, :subject => "Bestellung beendet: #{self.name}"}
# ).save!
def self.from_template(template, vars, attributes)
view = ActionView::Base.new(Rails::Configuration.new.view_path, {}, MessagesController.new)
new(attributes.merge(:body => view.render(:file => "messages/#{template}.rhtml", :locals => vars)))
end
end

316
app/models/order.rb Normal file
View file

@ -0,0 +1,316 @@
class Order < ActiveRecord::Base
has_many :order_articles, :dependent => :destroy
has_many :articles, :through => :order_articles
has_many :group_orders, :dependent => :destroy
has_many :order_groups, :through => :group_orders
has_many :order_article_results, :dependent => :destroy
has_many :group_order_results, :dependent => :destroy
belongs_to :supplier
belongs_to :updated_by, :class_name => "User", :foreign_key => "updated_by_user_id"
validates_length_of :name, :in => 2..50
validates_presence_of :starts
validates_presence_of :supplier_id
validates_inclusion_of :finished, :in => [true, false]
validates_numericality_of :invoice_amount, :deposit, :deposit_credit
validate_on_create :include_articles
# attr_accessible :name, :supplier, :starts, :ends, :note, :invoice_amount, :deposit, :deposit_credit, :invoice_number, :invoice_date
#use plugin to make Order commentable
acts_as_commentable
# easyier find of next or previous model
acts_as_ordered :order => "ends"
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def invoice_amount=(amount)
self[:invoice_amount] = FoodSoft::delocalizeDecimalString(amount)
end
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def deposit=(deposit)
self[:deposit] = FoodSoft::delocalizeDecimalString(deposit)
end
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def deposit_credit=(deposit)
self[:deposit_credit] = FoodSoft::delocalizeDecimalString(deposit)
end
# Create or destroy OrderArticle associations on create/update
def article_ids=(ids)
# fetch selected articles
articles_list = Article.find(ids)
# create new order_articles
(articles_list - articles).each { |article| order_articles.build(:article => article) }
# delete old order_articles
articles.reject { |article| articles_list.include?(article) }.each do |article|
order_articles.detect { |order_article| order_article.article_id == article.id }.destroy
end
end
# Returns all current orders, i.e. orders that are not finished and the current time is between the order's start and end time.
def self.find_current
find(:all, :conditions => ['finished = ? AND starts < ? AND (ends IS NULL OR ends > ?)', false, Time.now, Time.now], :order => 'ends desc', :include => :supplier)
end
# Returns true if this is a current order (not finished and current time matches starts/ends).
def current?
!finished? && starts < Time.now && (!ends || ends > Time.now)
end
# Returns all finished or expired orders, exclude booked orders
def self.find_finished
find(:all, :conditions => ['(finished = ? OR ends < ?) AND booked = ?', true, Time.now, false], :order => 'ends desc', :include => :supplier)
end
# Return all booked Orders
def self.find_booked
find :all, :conditions => ['booked = ?', true], :order => 'ends desc', :include => :supplier
end
# search GroupOrder of given OrderGroup
def group_order(ordergroup)
unless finished
return group_orders.detect {|o| o.order_group_id == ordergroup.id}
else
return group_order_results.detect {|o| o.group_name == ordergroup.name}
end
end
# Returns OrderArticles in a nested Array, grouped by category and ordered by article name.
# The array has the following form:
# e.g: [["drugs",[teethpaste, toiletpaper]], ["fruits" => [apple, banana, lemon]]]
def get_articles
articles= order_articles.find :all, :include => :article, :order => "articles.name"
articles_by_category= Hash.new
ArticleCategory.find(:all).each do |category|
articles_by_category.merge!(category.name.to_s => articles.select {|order_article| order_article.article.article_category == category})
end
# add articles without a category
articles_by_category.merge!( "--" => articles.select {|order_article| order_article.article.article_category == nil})
# return "clean" hash, sorted by category.name
return articles_by_category.reject {|category, order_articles| order_articles.empty?}.sort
# it could be so easy ... but that doesn't work for empty category-ids...
# order_articles.group_by {|a| a.article.article_category}.sort {|a, b| a[0].name <=> b[0].name}
end
# Returns the defecit/benefit for the foodcoop
def fcProfit(with_markup = true)
groups_sum = with_markup ? sumPrice("groups") : sumPrice("groups_without_markup")
groups_sum - invoice_amount + deposit - deposit_credit
end
# Returns the all round price of a finished order
# "groups" returns the sum of all GroupOrderResults
# "clear" returns the price without tax, deposit and markup
# "gross" includes tax and deposit. this amount should be equal to suppliers bill
# "fc", guess what...
# for unfinished orders it returns the gross price
def sumPrice(type = "gross")
sum = 0
if finished?
if type == "groups"
for groupResult in group_order_results
for result in groupResult.group_order_article_results
sum += result.order_article_result.gross_price * result.quantity
end
end
elsif type == "groups_without_markup"
for groupResult in group_order_results
for result in groupResult.group_order_article_results
oar = result.order_article_result
sum += (oar.net_price + oar.deposit) * (1 + oar.tax/100) * result.quantity
end
end
else
for article in order_article_results
case type
when 'clear'
sum += article.units_to_order * article.unit_quantity * article.net_price
when 'gross'
sum += article.units_to_order * article.unit_quantity * (article.net_price + article.deposit) * (article.tax / 100 + 1)
when "fc"
sum += article.units_to_order * article.unit_quantity * article.gross_price
end
end
end
else
for article in order_articles
sum += article.units_to_order * article.article.gross_price * article.article.unit_quantity
end
end
sum
end
# Finishes this order. This will set the finish property to "true" and the end property to the current time.
# Ignored if the order is already finished.
# this will also copied the results into OrderArticleResult, GroupOrderArticleResult and GroupOrderResult
def finish(user)
unless finished?
transaction do
#saves ordergroups, which take part in this order
self.group_orders.each do |go|
group_order_result = GroupOrderResult.create!(:order => self,
:group_name => go.order_group.name,
:price => go.price)
end
# saves every article of the order
self.get_articles.each do |category, articles|
articles.each do |oa|
if oa.units_to_order >= 1 # save only successful ordered articles!
article_result = OrderArticleResult.new(:order => self,
:name => oa.article.name,
:unit => oa.article.unit,
:net_price => oa.article.net_price,
:gross_price => oa.article.gross_price,
:tax => oa.article.tax,
:deposit => oa.article.deposit,
:fc_markup => FoodSoft::getPriceMarkup,
:order_number => oa.article.order_number,
:unit_quantity => oa.article.unit_quantity,
:units_to_order => oa.units_to_order)
article_result.save
# saves the ordergroup results, belonging to the saved orderd article
oa.group_order_articles.each do |goa|
result = goa.orderResult
# find appropriate GroupOrderResult
group_order_result = GroupOrderResult.find(:first, :conditions => ['order_id = ? AND group_name = ?', self.id, goa.group_order.order_group.name])
group_order_article_result = GroupOrderArticleResult.new(:order_article_result => article_result,
:group_order_result => group_order_result,
:quantity => result[:total],
:tolerance => result[:tolerance])
group_order_article_result.save! if (group_order_article_result.quantity > 0)
end
end
end
end
# set new order state (needed by notifyOrderFinished)
self.finished = true
self.ends = Time.now
self.updated_by = user
# delete data, which is no longer required, because everything is now in the result-tables
self.group_orders.each do |go|
go.destroy
end
self.order_articles.each do |oa|
oa.destroy
end
self.save!
# Update all GroupOrder.price
self.updateAllGroupOrders
# notify order groups
notifyOrderFinished
end
end
end
# Updates the ordered quantites of all OrderArticles from the GroupOrderArticles.
def updateQuantities
orderArticles = Hash.new # holds the list of updated OrderArticles indexed by their id
# Get all GroupOrderArticles for this order and update OrderArticle.quantity/.tolerance/.units_to_order from them...
articles = GroupOrderArticle.find(:all, :conditions => ['group_order_id IN (?)', group_orders.collect { | o | o.id }], :include => [:order_article])
for article in articles
if (orderArticle = orderArticles[article.order_article.id.to_s])
# OrderArticle has already been fetched, just update...
orderArticle.quantity = orderArticle.quantity + article.quantity
orderArticle.tolerance = orderArticle.tolerance + article.tolerance
orderArticle.units_to_order = orderArticle.article.calculateOrderQuantity(orderArticle.quantity, orderArticle.tolerance)
else
# First update to OrderArticle, need to store in orderArticle hash...
orderArticle = article.order_article
orderArticle.quantity = article.quantity
orderArticle.tolerance = article.tolerance
orderArticle.units_to_order = orderArticle.article.calculateOrderQuantity(orderArticle.quantity, orderArticle.tolerance)
orderArticles[orderArticle.id.to_s] = orderArticle
end
end
# Commit changes to database...
OrderArticle.transaction do
orderArticles.each_value { | value | value.save! }
end
end
# Updates the "price" attribute of GroupOrders or GroupOrderResults
# This will be either the maximum value of a current order or the actual order value of a finished order.
def updateAllGroupOrders
unless finished?
group_orders.each do |groupOrder|
groupOrder.updatePrice
groupOrder.save
end
else #for finished orders
group_order_results.each do |groupOrderResult|
groupOrderResult.updatePrice
end
end
end
# Sets "booked"-attribute to true and updates all OrderGroup_account_balances
def balance(user)
raise "Bestellung wurde schon abgerechnet" if self.booked
transaction_note = "Bestellung: #{name}, von #{starts.strftime('%d.%m.%Y')} bis #{ends.strftime('%d.%m.%Y')}"
transaction do
# update OrderGroups
group_order_results.each do |result|
price = result.price * -1 # decrease! account balance
OrderGroup.find_by_name(result.group_name).addFinancialTransaction(price, transaction_note, user)
end
self.booked = true
self.updated_by = user
self.save!
end
end
# returns the corresponding message for the status
def status
if !self.finished? && self.ends > Time.now
_("running")
elsif !self.finished? && self.ends < Time.now
_("expired")
elsif self.finished? && !self.booked?
_("finished")
else
_("balanced")
end
end
protected
def validate
errors.add(:ends, "muss nach dem Bestellstart liegen (oder leer bleiben)") if (ends && starts && ends <= starts)
end
def include_articles
errors.add(:order_articles, _("There must be at least one article selected")) if order_articles.empty?
end
private
# Sends "order finished" messages to users who have participated in this order.
def notifyOrderFinished
# Loop through GroupOrderResults for this order:
for group_order in self.group_order_results
order_group = OrderGroup.find_by_name(group_order.group_name)
# Determine group users that want a notification message:
users = order_group.users.reject{|u| u.settings["notify.orderFinished"] != '1'}
unless (users.empty?)
# Assemble the order message text:
results = group_order.group_order_article_results.find(:all, :include => [:order_article_result])
# Create user notification messages:
recipients = users.collect{|u| u.nick}.join(', ')
for user in users
Message.from_template(
'order_finished',
{:user => user, :group => order_group, :order => self, :results => results, :total => group_order.price},
{:recipient_id => user.id, :recipients => recipients, :subject => "Bestellung beendet: #{self.name}"}
).save!
end
end
end
end
end

View file

@ -0,0 +1,29 @@
# An OrderArticle represents a single Article that is part of an Order.
#
# Properties:
# * order_id (int): association to the Order
# * article_id (int): association to the Article
# * quantity (int): number of items ordered by all OrderGroups for this order
# * tolerance (int): number of items ordered as tolerance by all OrderGroups for this order
# * units_to_order (int): number of packaging units to be ordered according to the order quantity/tolerance
#
class OrderArticle < ActiveRecord::Base
# gettext-option
untranslate_all
belongs_to :order
belongs_to :article
has_many :group_order_articles, :dependent => :destroy
validates_presence_of :order_id
validates_presence_of :article_id
validates_uniqueness_of :article_id, :scope => :order_id # an article can only have one record per order
private
def validate
errors.add(:article, "muss angegeben sein und einen aktuellen Preis haben") if !(article = Article.find(article_id)) || article.gross_price.nil?
end
end

View file

@ -0,0 +1,73 @@
# An OrderArticleResult represents a single Article that is part of a *finished* Order.
#
# Properties:
# * order_id (int): association to the Order
# * name (string): article name
# * unit (string)
# * note (string): for post-editing the ordered article. informations like "new tax is ..."
# * net_price (decimal): the net price
# * gross_price (decimal): incl tax, deposit, fc-markup
# * tax (int)
# * deposit (decimal)
# * fc_markup (float)
# * order_number (string)
# * unit_quantity (int): the internal(FC) size of trading unit
# * units_to_order (int): number of packaging units to be ordered according to the order quantity/tolerance
#
class OrderArticleResult < ActiveRecord::Base
belongs_to :order
has_many :group_order_article_results, :dependent => :destroy
validates_presence_of :name, :unit, :net_price, :gross_price, :tax, :deposit, :fc_markup, :unit_quantity, :units_to_order
validates_numericality_of :net_price, :gross_price, :deposit, :unit_quantity, :units_to_order
validates_length_of :name, :minimum => 4
def make_gross # calculate the gross price and sets the attribute
self.gross_price = ((net_price + deposit) * (tax / 100 + 1) * (fc_markup / 100 + 1))
end
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def net_price=(net_price)
self[:net_price] = FoodSoft::delocalizeDecimalString(net_price)
end
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def tax=(tax)
self[:tax] = FoodSoft::delocalizeDecimalString(tax)
end
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def deposit=(deposit)
self[:deposit] = FoodSoft::delocalizeDecimalString(deposit)
end
# Custom attribute setter that accepts decimal numbers using localized decimal separator.
def units_to_order=(units_to_order)
self[:units_to_order] = FoodSoft::delocalizeDecimalString(units_to_order)
end
# counts from every GroupOrderArticleResult for this ArticleResult
# Return a hash with the total quantity (in Article-units) and the total (FC) price
def total
quantity = 0
price = 0
for result in self.group_order_article_results
quantity += result.quantity
price += result.quantity * self.gross_price
end
return {:quantity => quantity, :price => price}
end
# updates the price attribute for all appropriate GroupOrderResults
def after_update
group_order_article_results.each {|result| result.group_order_result.updatePrice}
end
protected
def validate
errors.add(:net_price, "should be positive") unless net_price.nil? || net_price > 0
end
end

119
app/models/order_group.rb Normal file
View file

@ -0,0 +1,119 @@
# OrderGroups can order, they are "children" of the class Group
#
# OrderGroup have the following attributes, in addition to Group
# * account_balance (decimal)
# * account_updated (datetime)
# * actual_size (int) : how many persons are ordering through the OrderGroup
class OrderGroup < Group
has_many :financial_transactions, :dependent => :destroy
has_many :group_orders, :dependent => :destroy
has_many :orders, :through => :group_orders
has_many :group_order_article_results, :through => :group_orders # TODO: whats this???
has_many :group_order_results, :finder_sql => 'SELECT * FROM group_order_results as r WHERE r.group_name = "#{name}"'
validates_inclusion_of :actual_size, :in => 1..99
validates_numericality_of :account_balance, :message => 'ist keine gültige Zahl'
attr_accessible :actual_size, :account_updated
# messages
ERR_NAME_IS_USED_IN_ARCHIVE = "Der Name ist von einer ehemaligen Gruppe verwendet worden."
# if the order_group.name is changed, group_order_result.name has to be adapted
def before_update
ordergroup = OrderGroup.find(self.id)
unless (ordergroup.name == self.name) || ordergroup.group_order_results.empty?
# rename all finished orders
for result in ordergroup.group_order_results
result.update_attribute(:group_name, self.name)
end
end
end
# Returns the available funds for this order group (the account_balance minus price of all non-booked GroupOrders of this group).
# * excludeGroupOrder (GroupOrder): exclude this GroupOrder from the calculation
def getAvailableFunds(excludeGroupOrder = nil)
funds = account_balance
for order in GroupOrder.find_all_by_order_group_id(self.id)
unless order == excludeGroupOrder
funds -= order.price
end
end
for order_result in self.findFinishedNotBooked
funds -= order_result.price
end
return funds
end
# Creates a new FinancialTransaction for this OrderGroup and updates the account_balance accordingly.
# Throws an exception if it fails.
def addFinancialTransaction(amount, note, user)
transaction do
trans = FinancialTransaction.new(:order_group => self, :amount => amount, :note => note, :user => user)
trans.save!
self.account_balance += trans.amount
self.account_updated = trans.created_on
save!
notifyNegativeBalance(trans)
end
end
# Returns all GroupOrders by this group that are currently running.
def findCurrent
group_orders.find(:all, :conditions => ["orders.finished = ? AND orders.starts < ? AND (orders.ends IS NULL OR orders.ends > ?)", false, Time.now, Time.now], :include => :order)
end
#find expired (lapsed) but not manually finished orders
def findExpiredOrders
group_orders.find(:all, :conditions => ["orders.ends < ?", Time.now], :include => :order, :order => 'orders.ends DESC')
end
# Returns all GroupOrderResults by this group that are finished but not booked yet.
def findFinishedNotBooked
GroupOrderResult.find(:all,
:conditions => ["group_order_results.group_name = ? AND group_order_results.order_id = orders.id AND orders.finished = ? AND orders.booked = ? ", self.name, true, false],
:include => :order,
:order => 'orders.ends DESC')
end
# Returns all GroupOrderResults for booked orders
def findBookedOrders(limit = false, offset = 0)
GroupOrderResult.find(:all,
:conditions => ["group_order_results.group_name = ? AND group_order_results.order_id = orders.id AND orders.finished = ? AND orders.booked = ? ", self.name, true, true],
:include => :order,
:order => "orders.ends DESC",
:limit => limit,
:offset => offset)
end
private
# If this order group's account balance is made negative by the given/last transaction,
# a message is sent to all users who have enabled notification.
def notifyNegativeBalance(transaction)
# Notify only when order group had a positive balance before the last transaction:
if (transaction.amount < 0 && self.account_balance < 0 && self.account_balance - transaction.amount >= 0)
users = self.users.reject{|u| u.settings["notify.negativeBalance"] != '1'}
unless (users.empty?)
recipients = users.collect{|u| u.nick}.join(', ')
for user in users
Message.from_template(
'negative_balance',
{:user => user, :group => self, :transaction => transaction},
{:recipient_id => user.id, :recipients => recipients, :subject => "Gruppenkonto im Minus"}
).save!
end
end
end
end
# before create or update, check if the name is already used in GroupOrderResults
def validate_on_create
errors.add(:name, ERR_NAME_IS_USED_IN_ARCHIVE) unless GroupOrderResult.find_all_by_group_name(self.name).empty?
end
def validate_on_update
ordergroup = OrderGroup.find(self.id)
errors.add(:name, ERR_NAME_IS_USED_IN_ARCHIVE) unless ordergroup.name == self.name || GroupOrderResult.find_all_by_group_name(self.name).empty?
end
end

View file

@ -0,0 +1,12 @@
class SharedArticle < ActiveRecord::Base
# gettext-option
untranslate_all
# connect to database from sharedLists-Application
SharedArticle.establish_connection(FoodSoft::get_shared_lists_config)
# set correct table_name in external DB
set_table_name :articles
belongs_to :shared_supplier, :foreign_key => :supplier_id
end

View file

@ -0,0 +1,17 @@
class SharedSupplier < ActiveRecord::Base
# used for gettext
untranslate_all
# connect to database from sharedLists-Application
SharedSupplier.establish_connection(FoodSoft::get_shared_lists_config)
# set correct table_name in external DB
set_table_name :suppliers
has_one :supplier
has_many :shared_articles, :foreign_key => :supplier_id
# save the lists as an array
serialize :lists
end

67
app/models/supplier.rb Normal file
View file

@ -0,0 +1,67 @@
class Supplier < ActiveRecord::Base
has_many :articles, :dependent => :destroy
has_many :orders
attr_accessible :name, :address, :phone, :phone2, :fax, :email, :url, :contact_person, :customer_number, :delivery_days, :order_howto, :note, :shared_supplier_id, :min_order_quantity
validates_length_of :name, :in => 4..30
validates_uniqueness_of :name
validates_length_of :phone, :in => 8..20
validates_length_of :address, :in => 8..50
# for the sharedLists-App
belongs_to :shared_supplier
# Returns all articles for this supplier that are available and have a valid price, grouped by article category and ordered by name.
def getArticlesAvailableForOrdering
articles = Article.find(:all, :conditions => ['supplier_id = ? AND availability = ?', self.id, true], :order => 'article_categories.name, articles.name', :include => :article_category)
articles.select {|article| article.gross_price}
end
# sync all articles with the external database
# returns an array with articles(and prices), which should be updated (to use in a form)
# also returns an array with outlisted_articles, which should be deleted
def sync_all
updated_articles = Array.new
outlisted_articles = Array.new
for article in articles.find(:all, :order => "article_categories.name", :include => :article_category)
# try to find the associated shared_article
shared_article = article.shared_article
if shared_article
# article will be updated
# skip if shared_article has not been changed
unequal_attributes = article.shared_article_changed?
unless unequal_attributes.blank?
# update objekt but don't save it
# try to convert different units
new_price, new_unit_quantity = article.convert_units
if new_price and new_unit_quantity
article.net_price = new_price
article.unit_quantity = new_unit_quantity
else
article.net_price = shared_article.price
article.unit_quantity = shared_article.unit_quantity
article.unit = shared_article.unit
end
# update other attributes
article.attributes = {
:name => shared_article.name,
:manufacturer => shared_article.manufacturer,
:origin => shared_article.origin,
:shared_updated_on => shared_article.updated_on,
:tax => shared_article.tax,
:deposit => shared_article.deposit,
:note => shared_article.note
}
updated_articles << [article, unequal_attributes]
end
else
# article isn't in external database anymore
outlisted_articles << article
end
end
return [updated_articles, outlisted_articles]
end
end

60
app/models/task.rb Normal file
View file

@ -0,0 +1,60 @@
class Task < ActiveRecord::Base
has_many :assignments, :dependent => :destroy
has_many :users, :through => :assignments
belongs_to :group
# form will send user in string. responsibilities will added later
attr_protected :users
validates_length_of :name, :minimum => 3
def is_assigned?(user)
self.assignments.detect {|ass| ass.user_id == user.id }
end
def is_accepted?(user)
self.assignments.detect {|ass| ass.user_id == user.id && ass.accepted }
end
def enough_users_assigned?
assignments.find_all_by_accepted(true).size >= required_users ? true : false
end
# extracts nicknames from a comma seperated string
# and makes the users responsible for the task
def user_list=(string)
@user_list = string.split(%r{,\s*})
new_users = @user_list - users.collect(&:nick)
old_users = users.reject { |user| @user_list.include?(user.nick) }
logger.debug "New users: #{new_users}"
logger.debug "Old users: #{old_users}"
self.class.transaction do
# delete old assignments
if old_users.any?
assignments.find(:all, :conditions => ["user_id IN (?)", old_users.collect(&:id)]).each(&:destroy)
end
# create new assignments
new_users.each do |nick|
user = User.find_by_nick(nick)
if user.blank?
errors.add(:user_list)
else
if user == User.current_user
# current_user will accept, when he puts himself to the list of users
self.assignments.build :user => user, :accepted => true
else
# normal assignement
self.assignments.build :user => user
end
end
end
end
end
def user_list
@user_list ||= users.collect(&:nick).join(", ")
end
end

174
app/models/user.rb Normal file
View file

@ -0,0 +1,174 @@
require 'digest/sha1'
# A foodsoft user.
#
# * memberships
# * groups
# * first_name, last_name, email, phone, address
# * nick
# * password (stored as a hash)
# * settings (user properties via acts_as_configurable plugin)
# specific user rights through memberships (see Group)
class User < ActiveRecord::Base
has_many :memberships, :dependent => :destroy
has_many :groups, :through => :memberships
has_many :assignments, :dependent => :destroy
has_many :tasks, :through => :assignments
attr_accessible :nick, :first_name, :last_name, :email, :phone, :address
validates_length_of :nick, :in => 2..25
validates_uniqueness_of :nick
validates_format_of :email, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
validates_uniqueness_of :email
validates_length_of :first_name, :in => 2..50
# Adds support for configuration settings (through "settings" attribute).
acts_as_configurable
# makes the current_user (logged-in-user) available in models
cattr_accessor :current_user
# User settings keys
# returns the User-settings and the translated description
def self.setting_keys
settings_hash = {
"notify.orderFinished" => _('Get message with order result'),
"notify.negativeBalance" => _('Get message if negative account balance'),
"messages.sendAsEmail" => _('Get messages as emails'),
"profile.phoneIsPublic" => _('Phone is visible for foodcoop members'),
"profile.emailIsPublic" => _('Email is visible for foodcoop members'),
"profile.nameIsPublic" => _('Name is visible for foodcoop members')
}
return settings_hash
end
# retuns the default setting for a NEW user
# for old records nil will returned
# TODO: integrate default behaviour in acts_as_configurable plugin
def settings_default(setting)
# define a default for the settings
defaults = {
"messages.sendAsEmail" => true
}
return true if self.new_record? && defaults[setting]
end
# Sets the user's password. It will be stored encrypted along with a random salt.
def password=(password)
salt = [Array.new(6){rand(256).chr}.join].pack("m").chomp
self.password_hash, self.password_salt = Digest::SHA1.hexdigest(password + salt), salt
end
# Returns true if the password argument matches the user's password.
def has_password(password)
Digest::SHA1.hexdigest(password + self.password_salt) == self.password_hash
end
#Sets the passwort, and if fails it returns error-messages (see above)
def set_password(options = {:required => false}, password = nil, confirmation = nil)
required = options[:required]
if required && (password.nil? || password.empty?)
self.errors.add_to_base _('Password is required')
elsif !password.nil? && !password.empty?
if password != confirmation
self.errors.add_to_base _("Passwords doesn't match")
elsif password.length < 5 || password.length > 25
self.errors.add_to_base _('Password-length has to be between 5 and 25 characters')
else
self.password = password
end
end
end
# Returns a random password.
def new_random_password(size = 3)
c = %w(b c d f g h j k l m n p qu r s t v w x z ch cr fr nd ng nk nt ph pr rd sh sl sp st th tr)
v = %w(a e i o u y)
f, r = true, ''
(size * 2).times do
r << (f ? c[rand * c.size] : v[rand * v.size])
f = !f
end
r
end
# Checks the admin role
def role_admin?
groups.detect {|group| group.role_admin?}
end
# Checks the finance role
def role_finance?
groups.detect {|group| group.role_finance?}
end
# Checks the article_meta role
def role_article_meta?
groups.detect {|group| group.role_article_meta?}
end
# Checks the suppliers role
def role_suppliers?
groups.detect {|group| group.role_suppliers?}
end
# Checks the orders role
def role_orders?
groups.detect {|group| group.role_orders?}
end
# Returns the user's OrderGroup or nil if none found.
def find_ordergroup
groups.find(:first, :conditions => "type = 'OrderGroup'")
end
# Find all tasks, for which the current user should be responsible
# but which aren't accepted yet
def unaccepted_tasks
# this doesn't work. Produces "undefined method", when later use task.users... Rails Bug?
# self.tasks.find :all, :conditions => ["accepted = ?", false], :order => "due_date DESC"
Task.find_by_sql ["SELECT t.* FROM tasks t, assignments a, users u
WHERE u.id = a.user_id
AND t.id = a.task_id
AND u.id = ?
AND a.accepted = ?
AND t.done = ?
ORDER BY t.due_date ASC", self.id, false, false]
end
# Find all accepted tasks, which aren't done
def accepted_tasks
Task.find_by_sql ["SELECT t.* FROM tasks t, assignments a, users u
WHERE u.id = a.user_id
AND t.id = a.task_id
AND u.id = ?
AND a.accepted = ?
AND t.done = ?
ORDER BY t.due_date ASC", self.id, true, false]
end
# find all tasks in the next week (or another number of days)
def next_tasks(number = 7)
Task.find_by_sql ["SELECT t.* FROM tasks t, assignments a, users u
WHERE u.id = a.user_id
AND t.id = a.task_id
AND u.id = ?
AND t.due_date >= ?
AND t.due_date <= ?
AND t.done = ?
AND a.accepted = ?
ORDER BY t.due_date ASC", self.id, Time.now, number.days.from_now, false, true]
end
# returns true if user is a member of a given group
def is_member_of(group)
return true if group.users.detect {|user| user == self}
end
#Returns an array with the users groups (but without the OrderGroups -> because tpye=>"")
def member_of_groups()
self.groups.find(:all, :conditions => {:type => ""})
end
end